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.

Status: Live on production since September 3, 2026 — the meter catalogue with all three feeds (API calls, capability invocations, platform events) plus direct feeds for messages sent, import rows, bytes stored, workflow runs and AI tokens; per-tenant rates with dated three-level resolution; tenant-owned and platform limits enforced within fifteen minutes; hourly usage reports to the billing processor. What is honest to say: no meter carries a sell price yet and every tenant defaults to flat pricing, so nothing bills by usage until a platform admin decides a rate; limit alerts reach the platform’s event stream and health console, not yet an operator’s inbox; and the limits API is complete while its console page is not.

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 unit of count, tokens, bytes or seconds. Every primitive ships two defaults — {primitive}.api.read and {primitive}.api.write — that catch anything not named explicitly, plus render.page_view. Named meters carry their own price and cap: seldon.booking.created, hober.order.closed, one speaker.{channel}.sent per channel (SMS is a carrier charge, an in-app message is a row), korell.import.row, media.bytes.stored, daneel.action.run, and four beth.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
basis is sell (what the tenant pays), cost (what it costs to serve them) or rebate; scope is platform, tenant or sub_tenant; effectiveFrom is a UTC day. Money is integer: unitPriceMicros per perUnits units, 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 yields null, 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 window of hour, day or month, with an action of alert (the default — most operators want to know they are spending, not to have Saturday bookings refused), throttle (429 with Retry-After) or block (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 only Retry-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_crossed and arkady_limit.breached onto 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 pendingreported, failed, held (a net-negative hour is never sent) or drifted (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_rates and usage_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….

Arkady endpoints in the Foundation API reference OpenAPI 3.1 schema for Arkady with request/response shapes, parameters, and a try-it client.

Tenant surface

MethodPathPurpose
GET/arkady/v1/usagePer-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/ratesThe effective sell rate today for every visible billable meter; priced: false with null prices where nobody has priced it.
GET/arkady/v1/limitsThe limits this tenant owns. Platform ceilings are excluded by owner, so none can leak in.
POST/arkady/v1/limitsSet 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

MethodPathPurpose
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/ratesEvery rate ever written at one set of coordinates, newest first — “what did we charge in March, and who changed it”.
POST/arkady/v1/admin/ratesWrite 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/limitsEvery limit that could apply to a tenant: platform defaults, theirs, and their units’.
POST/arkady/v1/admin/limitsSet 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.