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",
  "received_at": "2026-08-19T04:00:03Z",
  "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.

received_at is the platform’s own ingest instant for the record, always UTC and always distinct from time (when the device says the sample happened): it is later than time when the device clock runs ahead or the sample arrives late, and it is an explicit null when no platform receipt was recorded — never filled in from time.

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; a span over 30 days is 400

limit

default 1000; an integer in [1, 10000]. 0, a negative number, a non-integer or a non-numeric value is 400 — never quietly replaced by the default

cursor

leave out on the first request; for the next page, pass the next_cursor from the previous response

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.

Knowing whether you have all of it

A query answers with a page and says, in the response, whether that page is the whole answer:

{ "data": [ { "time": "2026-08-19T04:00:00Z", "thing_id": "ROBOT-LAWSON-001",
              "payload": { "battery_pct": 82 } } ],
  "next_cursor": "eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xOVQwNDowMDowMFoi…",
  "has_more": true,
  "meta": { "window_from": "2026-08-18T04:00:00Z",
            "window_to":   "2026-08-19T04:00:00Z",
            "coverage": { "oldest": "2026-08-02T11:03:00Z",
                          "newest": "2026-08-19T03:59:58Z" } } }
  • has_more is observed, never inferred. It is true only when the service saw at least one matching record beyond the page it is returning. A page that exactly exhausts the filter is full and reports has_more: false — so len(data) == limit is not evidence of anything, and an empty data always means has_more: false.

  • next_cursor is present if and only if has_more. Pass it back as cursor to get the next page; when has_more is false the walk is over and there is no token to pass.

  • Rows come back newest first, ordered by (time DESC, thing_id DESC). Two samples from one device can share a millisecond, and ordering by time alone would leave that group’s internal order undefined — which lets a paged read skip or repeat rows across a page boundary. The tie-break makes the order total.

  • A page never splits a (time, thing_id) group. If one device’s one millisecond holds more rows than limit, that group comes back whole, so a page may legitimately hold more rows than limit asked for. That is a visible overshoot, not a silent truncation, and the alternative — cutting the group — would either lose rows or need a position inside it.

  • The cursor is a position in your own authorized set, not a credential. It is bound to the query that issued it (owner, scope, thing, app namespace and time window), and every page is authorized again as you. A cursor that arrives with a different query is 400; a malformed or truncated one is 400. Neither is a 403 — your access is not what is in question. So a grant revoked mid-walk stops the walk at the next page rather than being carried by the first decision.

  • A continuation that states no from/to inherits the window its walk began with, rather than starting a fresh default window. This is what makes a walk gapless: re-defaulting the window mid-walk would move it and drop the records that fell out of the new one, producing a short read that looks complete.

meta reports what the service actually did, and is worth reading rather than assuming:

Field

Meaning

meta.window_from / window_to

the window the service applied — it echoes your from/to, and shows the default (last 24 hours) when you sent neither. If you send a window and this does not match it, the service did not read what you asked for

meta.coverage.oldest / newest

how far back records for this filter actually go, as an observation

Warning

meta.coverage is an observation, not a retention promise — and neither is the 30-day window. window_from/window_to bound the question you asked; they say nothing about how long telemetry is stored or how long it will be kept. coverage reports where the records for this filter happen to start and end right now, which is a different fact from what the platform will hold tomorrow. Do not derive a retention or an archival window from either field, and do not treat an empty coverage as evidence about storage policy.

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; a span over 92 days is 400

max_buckets

optional cap on the number of returned buckets

As above, the window is a bound on the question — it is not how long usage is retained, and the default is not a promise that older buckets exist.

{ "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

# continue the walk: pass the next_cursor the previous call printed
fgai telemetry list --scope-id scp-store-robots --app-namespace clearjanitor \
  --cursor 'eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xOVQwNDowMDowMFoi…'

# 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