Primitive spec
Arkady
Usage metering. Arkady answers three questions about a tenant — what did they do, what does it cost, and are they over a cap — and reports the hourly figures to the platform bill. Every API route, every capability invocation and every declared meter is covered, and the coverage is checked on every build so it stays true a year from now rather than true on the day it shipped.
What it owns
The meter catalogue — declared in code, so the set of things that can be counted cannot drift with a database row — the hourly usage rollups per tenant and business unit, the rates that price a meter, the limits that cap it, and the usage reports that freeze what was told to the billing processor and at what price. Metering runs for every tenant regardless of pricing: it is simultaneously the abuse control, the transparency surface and the support tool. Only a tenant whose pricing mode is usage-based has quantities reported.
Arkady does not issue the invoice. The platform bill comes from the billing processor, and the usage report is the snapshot that can explain one of its line items later. It also does not replace request rate-limiting, which protects infrastructure over seconds; metering limits protect budgets and abuse posture over hours to a month, and a request the rate limiter refuses is never metered.
Concepts
- Meter
- A named, countable unit of tenant activity with a
unitofcount,tokens,bytesorseconds. Every primitive ships two defaults —{primitive}.api.readand{primitive}.api.write— that catch anything not named explicitly, plusrender.page_view. Named meters carry their own price and cap:seldon.booking.created,hober.order.closed, onespeaker.{channel}.sentper channel (SMS is a carrier charge, an in-app message is a row),korell.import.row,media.bytes.stored,daneel.action.run, and fourbeth.ai.tokens.*meters because input, output, cache-read and cache-write are priced an order of magnitude apart. Some meters are platform-only and never appear on a tenant surface. - What counts
- A successful API call (2xx or 3xx — a 4xx is not a billable action and a 5xx is not the tenant’s fault). A successful capability invocation from the console assistant, a workflow step or an outside agent — one invocation is one record, and a console request is counted once, never twice. A platform event, so a booking counts the same whichever door created it. And a handful of direct feeds at the point where the work happens: a message the provider accepted, a row an import wrote, bytes a media asset stored, a workflow run started, a model step’s tokens. Page views of a published site are counted server-side and attributed to the site’s owner — page loads, not in-app navigation, and a cached load is never seen. Sandbox is never metered, on any feed.
- Rollups
- Usage lands in hot counters and is folded into hourly rollups every five minutes, per tenant, business unit and meter. Quantities are signed, so a correction is a compensating entry rather than a delete. A window read defaults to the last 30 days and is capped at 92.
- Rate
basisissell(what the tenant pays),cost(what it costs to serve them) orrebate;scopeisplatform,tenantorsub_tenant;effectiveFromis a UTC day. Money is integer:unitPriceMicrosperperUnitsunits, one million micros to the dollar, so model input at $0.15 per million tokens is a whole number. Resolution is per day, most specific scope first, then the latest effective date, with code-shipped platform defaults underneath every row. An unpriced meter yieldsnull, never a fabricated $0. Pricing happens at read time, so re-pricing a month is a configuration change and not a data migration. Rate rows are never edited in place; a mistake is withdrawn and a new row written.- Limit
- A cap on one meter over a
windowofhour,dayormonth, with anactionofalert(the default — most operators want to know they are spending, not to have Saturday bookings refused),throttle(429 withRetry-After) orblock(402). A tenant owns its own limits and sees them all. The platform holds its own; a hidden platform limit is excluded from every list, 404s by id, and breaches with a generic 429 that carries onlyRetry-After— a clock is not a policy, but a number would be. Each limit is evaluated independently, so a tenant ceiling can never lift a platform one. - Enforcement within fifteen minutes
- Enforcement trails usage by up to fifteen minutes by contract, which is what makes total coverage affordable: a sweep every five minutes evaluates every limit against rollups plus the not-yet-flushed counters and writes one verdict per tenant; a request then does a single lookup and no arithmetic, before any work is done. Verdicts expire so a stale sweep degrades to a slightly old answer and never to a permanent one. Enforcement fails open: a broken meter must not stop a business. A tenant cannot lock itself out — the routes that lift a limit and the routes that log in are exempt — and writing a limit needs billing-management permission; a unit-scoped operator may only touch limits on their own unit.
- Alerts
- Crossing 80% and 100% of a limit emits
arkady_limit.threshold_crossedandarkady_limit.breachedonto the platform’s event stream, once per window; a platform limit’s breach also raises a signal on the health console. Delivery to an operator’s email or in-app inbox is not built yet. - UsageReport
- One row per tenant, meter and closed hour: the quantity, the unit price and the scope the rate resolved from, pushed to the billing processor with an idempotency key derived from those coordinates. Status runs
pending→reported,failed,held(a net-negative hour is never sent) ordrifted(the rollup moved after the hour was billed — flagged for a human, never silently re-pushed). The still-accumulating hour is never reported, because a processor deduplicating on the key would keep the first, smaller number. - In the tenant bundle
- Pirenne describes and exports a tenant’s own sell rates and tenant-wide limits as
usage_ratesandusage_limits, export-only: plan, apply and rollback skip them and say so. Platform rates, costs, platform limits, platform-only meters and the scope a price resolved from are never in a bundle.
API surface
Endpoints are versioned under /arkady/v1/. The tenant surface is scoped by the verified session — a unit-scoped operator sees only their unit’s rows, a tenant-wide operator sees per-unit rows plus the tenant bucket — and shows sell prices only. The /admin/ family is for platform staff and answers 403 to anyone else; it adds cost, margin and the resolving scope. Rate ids are arat…, limit ids alim….
Tenant surface
| Method | Path | Purpose |
|---|---|---|
GET | /arkady/v1/usage | Per-meter quantity and cost for a window (?from=, ?to= as ISO dates; default the last 30 days, at most 92), with totals that say how many lines were unpriced rather than pretending the gap is zero. |
GET | /arkady/v1/usage/{meterId} | Hourly series for one meter — the chart behind the panel. A platform-only meter 404s like one that does not exist. |
GET | /arkady/v1/rates | The effective sell rate today for every visible billable meter; priced: false with null prices where nobody has priced it. |
GET | /arkady/v1/limits | The limits this tenant owns. Platform ceilings are excluded by owner, so none can leak in. |
POST | /arkady/v1/limits | Set a cap or alert: meterId, window, threshold, optional action (default alert), subTenantId, note. 201 + Location. Needs billing-management permission. |
DELETE | /arkady/v1/limits/{limitId} | Withdraw a limit. Someone else’s 404s exactly like a missing one. |
Platform admin
| Method | Path | Purpose |
|---|---|---|
GET | /arkady/v1/admin/usage?tenantId= | One tenant’s per-meter totals over a window with sell, cost and margin, and the scope each price resolved from. Margin is null unless both bases priced every day of the window. |
GET | /arkady/v1/admin/usage/{meterId}?tenantId= | Hourly series for one meter, any tenant. |
GET | /arkady/v1/admin/usage-reports?tenantId= | What was told to the processor this tenant used, hour by hour, at what price, with the status of each push. |
GET | /arkady/v1/admin/rates | Every rate ever written at one set of coordinates, newest first — “what did we charge in March, and who changed it”. |
POST | /arkady/v1/admin/rates | Write a dated rate at platform, tenant or sub-tenant scope: basis, scope, meterId, effectiveFrom, unitPriceMicros, optional perUnits, scopeId, tenantId, note. USD only today. |
DELETE | /arkady/v1/admin/rates/{rateId} | Withdraw a rate entered in error. |
GET | /arkady/v1/admin/limits | Every limit that could apply to a tenant: platform defaults, theirs, and their units’. |
POST | /arkady/v1/admin/limits | Set a platform ceiling, or a limit on a tenant’s behalf; hidden: true keeps it out of the tenant’s view entirely. |
DELETE | /arkady/v1/admin/limits/{limitId} | Withdraw a platform or tenant limit. |
Example: a gym reads its month, sees its prices, and sets a cap
A gym that sends SMS class reminders reads three days of usage. Its SMS meter carries a tenant-scope rate; bookings and page views are unpriced, and the response says so instead of totalling them at zero.
GET /arkady/v1/usage?from=2026-09-01&to=2026-09-04
→ 200 OK
{
"tenantId": "t_01J8K2…",
"subTenantId": null,
"from": "2026-09-01T00:00:00+00:00",
"to": "2026-09-04T00:00:00+00:00",
"currency": "USD",
"items": [
{ "meterId": "speaker.sms.sent", "title": "Sms messages sent",
"unit": "count", "subTenantId": null, "quantity": 184,
"costMicros": 1472000, "currency": "USD", "unpricedDays": 0 },
{ "meterId": "seldon.booking.created", "title": "Bookings created",
"unit": "count", "subTenantId": null, "quantity": 312,
"costMicros": null, "currency": null, "unpricedDays": 3 },
{ "meterId": "render.page_view", "title": "Published site page views",
"unit": "count", "subTenantId": null, "quantity": 5920,
"costMicros": null, "currency": null, "unpricedDays": 3 }
],
"totals": { "quantity": 6416, "costMicros": 1472000, "unpricedLines": 2 },
"pricingSource": "rates"
}
“What am I actually charged?” is a separate read. The SMS rate is $0.008 a message — 8,000 micros per unit — and nothing else is priced.
GET /arkady/v1/rates
→ 200 OK
{
"tenantId": "t_01J8K2…",
"asOf": "2026-09-04",
"items": [
{ "meterId": "speaker.sms.sent", "title": "Sms messages sent", "unit": "count",
"unitPriceMicros": 8000, "perUnits": 1, "currency": "USD", "priced": true },
{ "meterId": "seldon.booking.created", "title": "Bookings created", "unit": "count",
"unitPriceMicros": null, "perUnits": null, "currency": null, "priced": false }
]
}
The owner wants to know when a reminder blast runs hot, so they set a daily alert on the SMS meter. The response is the tenant shape: no owner, no visibility — a tenant is never told that platform limits exist.
POST /arkady/v1/limits
{
"meterId": "speaker.sms.sent",
"window": "day",
"threshold": 500,
"action": "alert",
"note": "Class reminders should never need more than this."
}
→ 201 Created
Location: /arkady/v1/limits/alim01JB9F…
{
"id": "alim01JB9F…",
"meterId": "speaker.sms.sent",
"window": "day",
"threshold": 500,
"action": "alert",
"subTenantId": null,
"enabled": true,
"note": "Class reminders should never need more than this."
}
A block is a real budget stop. A tenant that has capped booking writes at 2,000 an hour as a runaway-integration guard gets this back once the sweep trips it — refused before any work is done, with the numbers, because it is their own limit.
POST /seldon/v1/bookings
→ 402 Payment Required
Retry-After: 1260
{
"type": "https://foundation.dev/problems/usage-limit-exceeded",
"title": "Usage Limit Reached",
"status": 402,
"detail": "This tenant has used 2013 of its 2000 allowance for this activity. The limit resets in 1260 seconds.",
"instance": "/seldon/v1/bookings",
"retryAfter": 1260,
"limit": 2000,
"used": 2013
}
How it fits with the rest
flowchart TB Http[Every /v1 route] --> M(Arkady meters) Cap[Capability invocations
Beth, Daneel steps, Gaal agents] --> M Ev[Platform events
booking created, order closed] --> M Direct[Direct feeds
Speaker, Korell, Media, Daneel, AI tokens] --> M M --> Roll[Hourly rollups] Roll --> Rates[Rates
sell, cost, rebate] Roll --> Lim[Limits
alert, throttle, block] Lim -. one verdict per tenant .-> Http Lim -. one verdict per tenant .-> Cap Rates --> Rep[Usage reports
hourly, to the platform bill] Rates -. export-only .-> Pi[Pirenne bundle]
Every primitive feeds Arkady and none depends on it — a metering failure is logged and swallowed, never surfaced to the request that caused it. The capability path Gaal’s agents, Daneel’s steps and the console assistant share is metered and gated once, so a runaway agent is bounded by the same limit a runaway integration is. Whether a tenant is billed by usage at all is a setting on their Demerzel billing profile, flat by default; the processor issues the bill and the usage report is what explains it. Alerts land where Pelorat and the failure console read, and Pirenne carries a tenant’s own rates and limits in the bundle without ever carrying the platform’s.