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 |
|---|---|
|
required when |
|
the app slug; narrows the read to that app’s records |
|
optional; one device |
|
RFC 3339; default the last 24 hours; a span over 30 days is |
|
default 1000; an integer in |
|
leave out on the first request; for the next page, pass the |
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:
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 with403. This is the mechanism, not an oversight: it is a resource that does not match, rather than a rule that has to be remembered.app_namespacewithoutscope_idis400, not403. 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 the400as “fix the request”; treat a403as “the grant does not cover this”.A namespace containing
*,:,/or whitespace is400. 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_moreis 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 reportshas_more: false— solen(data) == limitis not evidence of anything, and an emptydataalways meanshas_more: false.next_cursoris present if and only ifhas_more. Pass it back ascursorto get the next page; whenhas_moreisfalsethe 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 thanlimit, that group comes back whole, so a page may legitimately hold more rows thanlimitasked 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 is400. Neither is a403— 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/toinherits 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 |
|---|---|
|
the window the service applied — it echoes your |
|
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 |
|---|---|
|
anything else with |
|
otherwise |
|
updating a grant’s caps cannot widen an app-scoped grant off its app; revoke and re-create instead |
uniqueness is |
one publisher may hold two grants over one scope, one per app; a repeat of the same triple is |
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 |
|---|---|
|
required |
|
|
|
optional; one device |
|
RFC 3339; default the last 30 days; a span over 92 days is |
|
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 fromsample_countand 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¶
Thing access model — scopes, grants, and the full capability set.
Identity, grants, and application access — how a decision is made.
Device attributes — the config surface, whose write authority is single-writer.