App-scoped telemetry and usage metering

Two narrower reads over the telemetry a device owner already holds, for the two marketplace roles that are neither the owner nor an operator:

  • a publisher supporting its own app on someone else’s robots reads only the records that app produced;

  • a license authority reads derived usage counters for the robots it licenses, and never the readings behind them.

Both are ordinary grants on the /access/v1/grants surface, and both are read on the /telemetry/v1 capability group. Neither needs a separate credential, endpoint host, or authentication scheme — see Registration.

The app namespace

A telemetry record carries an app_namespace: which app on the device produced the sample. It is a slug — ^[a-z][a-z0-9]*(-[a-z0-9]+)*$, 3 to 63 characters — and it is the same slug the publisher declares for its app at publish time, so one vocabulary spans the app, the grant, and the query.

{ "time": "2026-08-19T04:00:00Z",
  "thing_id": "ROBOT-LAWSON-001",
  "account_id": "acc-owner-01",
  "scope_id": "scp-store-robots",
  "app_namespace": "clearjanitor",
  "payload": { "battery_pct": 82 } }

The field is absent on a record whose device did not report one. Absent is a real, permanent state — it is what every record from a device that does not tag its telemetry looks like — and it never matches an app_namespace filter. An app-scoped reader therefore does not inherit a robot’s untagged history.

Warning

The app namespace is device-asserted. Nothing on the device plane attests which application spoke: every app on a robot shares the device’s one identity. The namespace narrows a read the owner has already granted, and is never on its own the reason a read is allowed — the trusted axes are the device identity and the owner and scope the platform resolves from it, server-side. Do not build a security control on the namespace: a compromised app on that robot can mis-tag its samples, and the bound on the damage is that the owner opted in per publisher and can revoke.

Reading one app’s telemetry

GET /telemetry/v1?scope_id=scp-store-robots&app_namespace=clearjanitor&from=…&to=…

Parameter

Notes

scope_id

required when app_namespace is present

app_namespace

the app slug; narrows the read to that app’s records

thing_id

optional; one device

from / to

RFC 3339; default the last 24 hours; maximum span 30 days

limit

default 1000, maximum 10000

Adding app_namespace changes which resource the read is authorized against. The whole-scope read and the app-scoped read are two disjoint resources, not a filter applied after a single decision. Three consequences you have to design around:

  1. An app-scoped grantee must always send app_namespace. The same request without it is judged against the whole-scope resource, which an app-scoped grant does not cover, and is refused with 403. This is the mechanism, not an oversight: it is a resource that does not match, rather than a rule that has to be remembered.

  2. app_namespace without scope_id is 400, not 403. A grant hangs off a scope, so an app-only resource is one no grant can ever produce. Serving it would return an authorization-shaped answer to a malformed request and send you hunting for a grant that was never the problem. Treat the 400 as “fix the request”; treat a 403 as “the grant does not cover this”.

  3. A namespace containing *, :, / or whitespace is 400. It is rejected before any authorization decision is made.

The grant an owner writes for a publisher

POST /access/v1/grants

{
  "grantee_account_id": "acc-publisher-01",
  "scope_id": "scp-store-robots",
  "caps": ["telemetry:read"],
  "app_namespace": "clearjanitor"
}

Rule

Behaviour

caps must be exactly ["telemetry:read"]

anything else with app_namespace present is 400

app_namespace must match the slug grammar

otherwise 400

app_namespace is set at creation

updating a grant’s caps cannot widen an app-scoped grant off its app; revoke and re-create instead

uniqueness is (grantee, scope, app_namespace)

one publisher may hold two grants over one scope, one per app; a repeat of the same triple is 409

Inside a grant, the app-scoped authorization replaces the whole-scope one rather than adding to it — naming an app never leaves the grantee holding the whole scope as well. Revoking the grant removes the app-scoped access with it.

The usage-metering read

GET /telemetry/v1/usage?scope_id=scp-store-robots&bucket=day&from=…&to=…

Authorized by its own capability, telemetry:usage-read, granted the same way:

POST /access/v1/grants

{ "grantee_account_id": "acc-licensor-01",
  "scope_id": "scp-store-robots",
  "caps": ["telemetry:usage-read"] }

Parameter

Notes

scope_id

required

bucket

hour (default) or day; anything else is 400

thing_id

optional; one device

from / to

RFC 3339; default the last 30 days; maximum span 92 days

max_buckets

optional cap on the number of returned buckets

{ "bucket": "day",
  "from": "2026-07-20T00:00:00Z",
  "to": "2026-08-19T00:00:00Z",
  "scope_id": "scp-store-robots",
  "items": [
    { "bucket_start": "2026-08-18T00:00:00Z",
      "bucket_end":   "2026-08-19T00:00:00Z",
      "thing_id": "ROBOT-LAWSON-001",
      "sample_count": 1440,
      "active_minutes": 372 }
  ] }
  • sample_count — how many records the robot produced in the bucket. A volume signal, not a duration.

  • active_minutes — how many distinct minutes the robot reported in at all. Reporting cadence is a device and app decision, so this is counted rather than derived from sample_count and an assumed rate: a chattier robot is not a harder-working one.

Two properties worth designing against:

There is no payload on this feed, ever. telemetry:usage-read is a distinct capability over a distinct resource, not a restricted mode of telemetry:read. A principal holding only it is refused /telemetry/v1 outright — the guarantee is an authorization fact, not a promise about which fields a response happens to carry.

A window that does not fit is refused, not truncated. If the request would produce more buckets than the maximum, the response is 400 telling you to narrow from/to, widen bucket, or filter by thing_id. You are metering from these numbers; a silently short page would under-report usage and look exactly like a quiet fleet.

From the CLI

# one app's telemetry on a shared scope
fgai telemetry list --scope-id scp-store-robots --app-namespace clearjanitor

# per-robot usage counters, daily
fgai telemetry usage-list --scope-id scp-store-robots --bucket day

# the owner side: grant, list, revoke
fgai access grants-create --body '{"grantee_account_id":"acc-publisher-01","scope_id":"scp-store-robots","caps":["telemetry:read"],"app_namespace":"clearjanitor"}'
fgai access grants-list
fgai access grants-delete --id <grant-id>

See also