| Internet-Draft | The Pulse Protocol | September 2026 |
| Petkov & Panitch | Expires 21 March 2027 | [Page] |
This document specifies Pulse, version 1.1 — the AI online charging protocol: the interface between an AI Gateway (the client) and the Charging Server (the server) by which AI service usage is authorised in real time, supervised under a granted budget, and settled. The spend guarantee is precisely stated: settled spend never exceeds the reported meter or the plan's ceiling, and serving exposure is bounded by the granted pool plus the completion of Calls already in flight when a stop lands. The protocol consists of two request/response operations over HTTPS with JSON bodies: Authorise and Report. It follows the reserve-then-settle discipline of telecom online charging (cf. Diameter Credit-Control, RFC 4006) applied to AI workloads.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 21 March 2027.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document. Code Components extracted from this document must include Revised BSD License text as described in Section 4.e of the Trust Legal Provisions and are provided without warranty as described in the Revised BSD License.¶
Providers and resellers of mass-market AI services cannot today guarantee that an individual subscriber will not overspend, nor apply a hard per-subscriber budget. Pulse provides that guarantee the way telecom charging does: no chargeable work proceeds without a prior authorisation carrying a budget, and all consumption is reported back and settled against it.¶
Pulse is not a Diameter application, but deliberately reuses proven Diameter Credit-Control concepts from [RFC4006]: session-based credit control with granted quota (here: the grant), reporting of used units, and credit pooling — a single abstract unit pool with per-unit-type multipliers (Section 6.3), mirroring the Diameter G-S-U-Pool-Reference mechanism. [RFC8506], which obsoletes [RFC4006], is the current specification. The name reflects the protocol's own rhythm — supervision happens on a regular pulse of reports (heartbeat reports, reporting cadence, settlement cycles).¶
This memo specifies the charging plane only: the two operations, their semantics, timing, idempotency, errors, and security requirements. The management plane (tenant onboarding, plan authoring, subscriber provisioning, credential administration, reporting) is a separate surface and is out of scope here.¶
Version 1 targets bounded synchronous and streaming Actions operated against one logical charging domain per tenant; an Action MUST be operated against the domain that opened it, and HTTP redirection (3xx) is not used. Quiet periods inside an Action are covered by heartbeat reports (Section 7.1). The following are deliberately deferred, not accidentally missing — each is named so an implementer never mistakes silence for support:¶
Batch / asynchronous work (a result returning hours later) has no contract in version 1.¶
Cross-region failover semantics (retrying against a different charging domain).¶
Pre-grants / bulk authorisation for latency amortisation (Section 13.5 states version 1's posture).¶
Multi-Action batch reporting / multiplexed transport (Section 13.5).¶
An interoperable call-signature profile (Section 7.2 — the field is reserved).¶
The unit-vocabulary catalogue contract (distribution of Action-Type unit definitions is a management-plane artefact; version 1 assumes out-of-band onboarding).¶
Serving-system provenance (which model or system actually served an Action — a self-attested, evidence-only telemetry block on reports). No version-1 contract; its design belongs with the reference-model phase, alongside the evidence-grade question any self-attested provenance raises.¶
A single owner's cap across several of its own operators — one company operating multiple tenants under a jurisdiction-mandated joint bound (the shape of, e.g., German gambling regulation). In version 1 a relation-scoped bound ranges within one tenant (Section 12.1); a cross-tenant bound has no contract.¶
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
| Term | Meaning |
|---|---|
| Charging Server ("the server") | The OCS instance implementing this protocol. |
| Gateway ("the client") | The authenticated system in the AI usage path that requests authorisation and reports consumption. |
| Tenant | The commercial customer of the platform; every credential is bound to one or more tenants. |
| Subscriber | An end user provisioned under a tenant, identified by accountRef. |
| Action | The chargeable unit of work (e.g. one agent task, one conversation exchange), identified by a client-asserted actionId. An Action contains one or more Calls. |
| Call | One inference invocation within an Action, identified by callId. |
| Grant | The server's authorisation to consume up to a stated quantity of pooled units before reporting back, valid until expiresAt. Exactly one grant is current per live Action; grants carry a strictly increasing generation number (grantSeq, +1 per mint — the ending itself mints the final generation, Section 3.5), and a client discards any grant older than one it has already seen. |
| Envelope | The always-fresh part of a response — serverTime and maxSchemaVersion on every response (error envelopes included); actionLive, currentGrantSeq, currentGrantId, and per-attempt unattributable on every 200 that identifies an opened Action (defined-absent on refusals of unopened Actions and on error envelopes). Computed at each processing, replays included; the client MUST obey the envelope over a replayed body. actionLive is monotonic — once false, never true again — and scoped precisely: false forbids lifecycle serving under any grant; a flat Action's work is authorised by its settled GRANT itself (Section 3.3), which is a record, not a serving grant subject to the stale-discard rule. |
| Cycle | One Report exchange: the server settles the reported consumption and, if the Action continues, issues the next grant. |
| Action Type | The declared, catalogue-defined kind of Action; it selects the price entry (the "law") the Action is rated under. |
| Ending | The terminal transition of an Action: CLOSE (client-declared final report), STOP (server-directed stop), or LAPSE (grant validity lapsed without a report). |
| Late window | The period after an ending — until max(endedAt + 24 h, expiresAt + 1 h) (Section 3.5, Section 7.6) — during which the server retains the Action's terminal state, honours retries, and can still settle late-arriving consumption. |
| Sponsor cap | An aggregate spend bound a sponsoring party declares across a whole population of subscribers (e.g. a reseller capping the total cost of a bundled AI benefit). Enforced at authorisation, attributed to the sponsor in refusals (Section 12.1). |
| Capacity pool | A tenant-declared provisioned serving rate that Actions of given types draw on. Finite and perishable: what is not admitted now is gone. Enforced at admission (Section 12.2). |
| Meter | The server's stored per-call cumulative high-water totals for an Action — the authoritative record of reported consumption. Settlement never exceeds the meter. |
| Ceiling / headroom | The ceiling is the maximum total charge an Action (or Action family, Section 9) may accumulate under its plan; headroom is what remains of it. |
| Minimum rate | The plan-declared serve rate below which degradation (Section 12.3) is not attempted and the honest answer is NO_CAPACITY. |
| Integration profile | The tested record of what a given gateway integration actually does (authorise-before-serving, stop obedience, report promptness, …), established by the conformance suite (Section 12.4). |
All normative statements about server behaviour ("the server MUST …") are guarantees a client may rely on; statements about client behaviour are conformance requirements for gateways.¶
Gateway Charging Server
| |
|--- POST /ocs/ai/v1/authorizations --------->| Authorise: may
|<-- GRANT {grantId, grantedUnits, | this Action
| reportAt, expiresAt, unitWeights} | proceed, and on
| | what budget?
| (gateway serves AI calls, metering |
| consumption against the granted pool) |
| |
|--- POST /ocs/ai/v1/grants/{id}/reports ---->| Report: usage
|<-- CONTINUE {next grant} or | so far. Settle
| STOP {stopReason} or | and re-grant,
| SETTLED | stop, or settle
| | finally.
| ... repeat cycles until an ending ... |
¶
An Action opens with exactly one successful Authorise and ends exactly once (CLOSE, STOP, or LAPSE). Between opening and ending, the gateway reports at the server-stated thresholds and the server answers each report with the next directive.¶
The protocol is deliberately two messages and no more:¶
Authorise — the client asks; the server answers GRANT or REFUSE. A refusal means no chargeable work may proceed; a grant means work up to the granted pool may proceed.¶
Report — the client reports cumulative consumption; the server settles and answers CONTINUE (with the next grant), STOP (a stop obligation: the client MUST cease serving the Action's traffic promptly), or SETTLED (final settlement acknowledged).¶
There is no server-initiated push channel in version 1. The stop obligation is delivered on
the response to a report; grant lapse (expiresAt) bounds how long an unreported Action can run.¶
An Action whose type is flat-priced settles at open: the Authorise response carries
decision: GRANT with settled: true and a grantId for correlation, and no reports are
expected. A flat Action ends at authorisation — its ending is CLOSE, its endedAt is the
response's serverTime, and its late window anchors there. Its envelope therefore
carries actionLive: false with terminal generation 2 — which is correct and does not
countermand the work: the settled GRANT itself is the complete authorisation for the flat
Action's work (Section 2, Envelope). The opening settlement carries a receipt of settlementId
alone (no units existed to accept). A Report addressed to its grantId never settles — an
identity match replays; anything else receives the terminal directive, with
unattributable: true if units were carried (Section 3.5). The report-borne controls (call
limits, runtime, signatures, inflation) do not apply to flat Actions by construction —
their controls are the authorisation-time checks (Section 6.2). All other price forms follow the
report lifecycle.¶
The client admits Calls; the server settles money. The client MUST NOT knowingly serve
consumption beyond the granted pool (checking each next Call against the remaining pool,
which it can compute from grantedUnits and unitWeights, is the expected mechanism) and
MUST report when the reportAt threshold is crossed. The server clamps every settlement at
the reported meter and at the Action's ceiling; overshoot exposure is bounded to one final
report. The permitted tail: consumption whose serving began before the report was
dispatched may complete under the old grant and is settled on the next cycle.¶
The server's clock is authoritative for lapse boundaries and time-band selection. A client MUST NOT assume its own clock decides either.¶
One in-flight Report per Action. The client MUST serialise its Reports: at most one Report (including its retries) is outstanding per Action at any time, and while a Report is outstanding the client MUST NOT admit new Calls — work already begun (the tail, first bullet above) completes. The server's convergence rules (Section 3.5) remain the safety net for the distributed-gateway case where two workers overlap despite this rule.¶
This section is the authority for every exchange; the prose of Section 6 and Section 7 restates it and never overrides it.¶
The gates — evaluated before the table, in this order, each state-neutral (Section 13.1) and
carrying no Action-state envelope: transport (wrong method → 405, wrong content type →
415) → authentication (401) → gateway ownership (403 — including a parentActionId owned
by another gateway, Section 9) → rate limiting (429) → parse and validation (400 — Section 4.3, Section 4.4,
Section 5) → identity resolution (404 — an unknown actionId or grantId, or a path grantId
that does not belong to the body's Action). Only a request that passes every gate reaches
the table.¶
Row evaluation: within a state, rows are evaluated in the listed order and the first matching row applies — the table is a function from (state, input) to one outcome.¶
States: NONE (the actionId is unclaimed) · LIVE(Gn) (open; one current grant,
generation n) · ENDED(e) (ended as e ∈ {CLOSE, STOP, LAPSE}; recognised) ·
FORGOTTEN. A flat-priced Action passes through LIVE for zero time: a successful flat
Authorise opens and ends it (ENDED(CLOSE)) in one transition. Generations:
every stored outcome mints a generation (grantSeq/outcome generation, strictly +1) —
cycles, the terminal transition, and late adjustments alike — so envelope ordering (Section 6.3)
and receipt ordering (Section 7.3) are total, and terminal state is sticky (actionLive never
returns to true).¶
In state NONE (a Report cannot reach NONE — the identity gate returns 404):¶
| # | Input | Outcome | Next state |
|---|---|---|---|
| 1 | Authorise, admissible |
GRANT (lifecycle: G1; flat: settled + endedAt) |
LIVE(G1); flat: ENDED(CLOSE)
|
| 2 | Authorise, refused | 200 REFUSE + reason (no identity retained) |
NONE
|
In state LIVE(Gn):¶
| # | Input | Outcome | Next state |
|---|---|---|---|
| 1 | Authorise, identity = opening identity | re-presentation of Gn + its baseline receipt |
LIVE(Gn)
|
| 2 | Authorise, other identity | 200 REFUSE / DUPLICATE_ACTION
|
LIVE(Gn)
|
| 3 | Report whose identity matches a retained outcome | that stored outcome replayed + fresh envelope |
LIVE(Gn)
|
| 4 | Report, final: true (addressed to any grant of this Action) |
the closing cycle against the current state: merge → settle the full unsettled meter delta → SETTLED + receipt (a close mints no serving grant; the outcome mints its generation) |
ENDED(CLOSE)
|
| 5 | Report addressed to the current grant Gn (calls[] MAY be empty — a heartbeat) |
the fresh cycle (the primary operation): merge → settle the full unsettled meter delta → evaluate ending conditions in precedence order (Section 7.4): none hold → CONTINUE(Gn+1); one holds → STOP(reason) + receipt |
LIVE(Gn+1) · ENDED(STOP)
|
| 6 | Report addressed to a superseded grant (non-final, no identity match) | meters max-merge; then ending conditions are evaluated (one raised by the merge ends the Action per precedence, settling at the meter → STOP path); none hold → CURRENT: the standing grant Gn re-presented + baselineCumulative (Gn's cycle baseline) + meterCumulative (the post-merge meter). No settlement, no mint. |
LIVE(Gn) · ENDED(STOP)
|
| 7 | Server event: grace-end without a report (now ≥ expiresAt + 5 s), parent cascade, administrative stop |
settle at the current meter, then the terminal transition (mints) |
ENDED(LAPSE or STOP)
|
In state ENDED(e) (while recognised):¶
| # | Input | Outcome | Next state |
|---|---|---|---|
| 1 | Authorise, identity = opening identity | terminal acknowledgement (decision: GRANT, settled where applicable, ending, endedAt, final receipt) |
ENDED(e)
|
| 2 | Authorise, other identity | 200 REFUSE / DUPLICATE_ACTION
|
ENDED(e)
|
| 3 | Report whose identity matches a retained outcome | that stored outcome replayed verbatim + fresh envelope |
ENDED(e)
|
| 4 | Report to a flat Action | terminal directive (SETTLED) — never settles; unattributable: true if units were carried (recorded, never charged) |
ENDED(e)
|
| 5 | Report, merge adds new consumption, settleable | late adjustment: terminal directive + adjustment: true + new settlementId + receipt (mints its outcome generation) |
ENDED(e)
|
| 6 | Report, merge adds new consumption, not settleable | terminal directive + unattributable: true, no settlementId; the unsettled range marks the Action — every later zero-merge response also carries unattributable: true until it settles or recognition lapses |
ENDED(e)
|
| 7 | Report, merge adds zero | terminal directive + current receipt (no adjustment; sticky unattributable where marked) |
ENDED(e)
|
The terminal directive applies to every ENDED-state response except row 3's verbatim
replays, and is a pure function of the ending: CLOSE → SETTLED · STOP → STOP with the
original stopReason (and limitOwner where it applied) · LAPSE → STOP (LAPSED) —
always with endedAt. The ENDED envelope: currentGrantSeq = the latest outcome
generation; currentGrantId = the last issued grant's id (the address late reports
use). A flat Action: generation 2 and the settled grant's id.¶
Arbitration: processing is serialised; the applicable rules are those of the state at processing time. The "final beats stop" precedence (Section 7.5) applies to conditions evaluated within one report's processing — never to transitions that already committed: a final report processed after an ending is late handling under the terminal directive.¶
Retention (uniform): the terminal outcome plus the last 4 non-terminal outcomes
(cycles and late adjustments share the ring), each carrying its generation. A retry whose
outcome has aged out takes its state's no-match row — safe by construction (Section 7.2's
absorption), though its original settlementId is then no longer re-presentable (Section 7.6).
Recognition (duplicate detection and the terminal directive) is unconditional until
max(endedAt + 24 h, expiresAt + 1 h) — an unseen asynchronous stop can never outlive
the window of the grant that hid it.¶
When recognition ends: ENDED → FORGOTTEN — the identity is discarded. Reports →
404, unattributable (Section 7.6); an Authorise on the id is evaluated fresh (this is the
documented reopen residual of Section 6.5).¶
| Operation | Method and path |
|---|---|
| Authorise |
POST /ocs/ai/v1/authorizations
|
| Report |
POST /ocs/ai/v1/grants/{grantId}/reports
|
Requests and responses are application/json bodies, in the JSON format of [RFC8259],
carried over HTTP [RFC9110] on TLS. TLS 1.2 [RFC5246] or later is REQUIRED
(TLS 1.3 [RFC8446] RECOMMENDED; configuration per [RFC9325]); plaintext HTTP
MUST be refused.
A non-POST method on these paths → 405; a non-JSON content type → 415. These two paths are
the only public charging surface.¶
Every request MUST carry a per-gateway bearer API key (Authorization: Bearer …). Keys are
issued per gateway, support dual-active rotation, and are revocable with effect within
seconds. Mutual TLS is a deployment option. OAuth2 client-credentials is planned for a later
protocol version and is not part of version 1.¶
Every field has exactly one JSON type; a value of the wrong type is malformed
(MALFORMED_REQUEST / MALFORMED_REPORT):¶
Decimal quantities — unit quantities, estimate values, grantedUnits, reportAt
values, unitWeights, quantumUnits, minRate, and every other decimal — are JSON
strings in canonical decimal rendering: plain notation, no exponent, no trailing zeros,
no trailing decimal point, no leading zeros (a bare 0 integer part is the only zero
prefix), no leading +, no -0, non-negative unless stated otherwise, scale ≤ 6.
Exactly one spelling of every value is valid; any other spelling is malformed — there is
no equivalence class of spellings anywhere in this protocol. (JSON numbers are IEEE-754
doubles and cannot carry scale-6 decimals faithfully; single-spelling strings also make
canonicalization exact, Section 6.5.) Money never appears on this wire (Section 15.7).¶
Identifiers (actionId, callId, grantId, currentGrantId, parentActionId,
tenantRef, accountRef, actionType, scope, timeBandId, settlementId): JSON
strings, ≤ 128 characters from [A-Za-z0-9._:-].¶
Display labels (limitOwner): UTF-8 JSON strings, ≤ 64 characters of printable text
— no control or bidirectional-control characters; clients render them as plain text,
never as markup. Presentation text declared with the relation, not an identifier.¶
Unit-type names (map keys in estimate, units, unitWeights, receipt maps):
strings matching [a-z0-9_]{1,64}.¶
Timestamps (expiresAt, serverTime, grantIssuedAt, endedAt, gatewayTime):
JSON strings in the [RFC3339] profile YYYY-MM-DDThh:mm:ss.sssZ — uppercase T and Z,
exactly three fractional digits, years 1970–9999, no leap seconds.¶
Counts and versions (schemaVersion, maxSchemaVersion, grantSeq,
currentGrantSeq, lawVersion, retryAfter, maxCallsPerAction, maxRuntimeSec,
maxConcurrentActions, enforcementWindowSec): JSON integers ≥ 0 and < 2⁵³.¶
Numeric domains (closed under the arithmetic): request quantities lie in [0, 10^9]
and weights in [0, 10^6], scale ≤ 6; derived pooled quantities (grantedUnits,
reportAt, quantumUnits, cyclePooledUnits, baselineCumulative/meterCumulative
pooled values) have their own domain [0, 10¹⁷), scale ≤ 6 — closed under 16 unit types ×
10⁹ × 10⁶. Response maps are bounded by the 16-unit-type limit; acceptedCalls by the
256-entry limit. The 10⁹ per-unit-per-Call cap is a hard operational bound: a gateway
MUST terminate or segment work before any Call's cumulative value reaches it — it is not
merely a parser limit.¶
Flags (final, settled, retryable, rateReduced, adjustment, clamped,
actionLive, unattributable, signaturesRequired): JSON booleans.¶
Maps: JSON objects whose keys are unit-type names and whose values are decimal strings as above.¶
Duplicate JSON keys anywhere in a body are malformed.¶
| Limit | Value | On violation |
|---|---|---|
| Request body size | ≤ 256 KB | 400 LIMIT_EXCEEDED
|
calls[] entries per report |
≤ 256 | 400 LIMIT_EXCEEDED
|
| Unit-map entries per Call | ≤ 16 | 400 LIMIT_EXCEEDED
|
| Per-call unit quantity | ≤ 10⁹ | 400 LIMIT_EXCEEDED
|
| Cumulative per-unit-type total per Action | ≤ 10⁹ | 400 LIMIT_EXCEEDED
|
Every request MUST carry schemaVersion (integer ≥ 1); every response echoes it and
additionally carries maxSchemaVersion. Two version layers with distinct roles: the URL
path (/v1/) changes only for incompatible protocol generations; schemaVersion
evolves compatibly within a generation, and a server MUST support every schema version
from 1 through its maxSchemaVersion within the path generation it serves.¶
Fields belong to schema versions. A client MUST NOT send a field beyond the version it declares; the server rejects fields not in the declared version (strict parse per version — unknown members are rejected, never ignored).¶
An unknown schemaVersion → 400 UNSUPPORTED_SCHEMA. If schemaVersion itself is
missing or unparseable, the error response carries maxSchemaVersion but cannot echo a
version (the one exception to echoing).¶
The server-side value sets (refusalReason, stopReason, errorCode, decision,
directive, ending, grade, reduceCause) are closed per
version, but clients MUST tolerate unknown strings in these fields — treating an unknown
refusalReason as a refusal, an unknown stopReason as a stop obligation, an unknown
errorCode as the error class its HTTP status indicates, an unknown directive as a
stop obligation, an unknown decision as a refusal, an unknown ending as STOP, an
unknown grade as BOUNDED, and an unknown reduceCause as CAPACITY.¶
Responses are version-bounded too: a server sends only fields belonging to the echoed
schemaVersion; a client reads responses tolerantly, acting on the fields of its
declared version and ignoring unknown members (response reading is tolerant; request
parsing stays strict).¶
The reason/code registries are owned by this document; new values are allocated only by a new schema version of this specification (no private-use values in version 1). While the series is unpublished, a draft MAY rename a registry value, recording the rename in its change history; once published, a renamed value is a new value. The registries and their allocation policy are stated in Section 17.¶
Protocol versions are cited major.minor. The major is the path generation (/v1/); the
minor identifies one compatible addition set within it, and each minor version binds to
exactly one wire schemaVersion. The wire carries only the integer — the dotted form is
the human citation, and "version 1" unqualified means the generation.¶
| Protocol version | Wire schemaVersion | Surface |
|---|---|---|
| 1.0 | 1 | The base contract (the draft-effortel-circ-05/-06 surface). |
| 1.1 | 2 | Adds the GROUP_VELOCITY refusal reason, the refusal fields automatedDecision and reviewContact (Section 6.4), and the per-call startedAt evidence field (Section 7.2). |
Fields versus values under version skew. A new field never crosses a version
boundary: a client MUST NOT send a field beyond its declared schemaVersion (strict parse
rejects it), and a server sends only the fields of the echoed version — a version-1.0
client therefore never sees automatedDecision or reviewContact. A new value in an
existing closed set MAY reach an older client: the tolerance fallbacks above exist for
exactly this skew, and the server always refuses with the true reason rather than
mislabelling it under an older value — a version-1.0 client refused for a group-velocity
bound receives refusalReason: GROUP_VELOCITY and treats it as the generic refusal the
fallback prescribes.¶
POST /ocs/ai/v1/authorizations¶
| Field | Requirement | Meaning |
|---|---|---|
schemaVersion
|
REQUIRED | See Section 5. |
actionId
|
REQUIRED | Client-asserted Action identity. SHOULD be asserted by the application originating the work; a gateway-fabricated id is permitted but downgrades attribution quality (known per contract). Never legally reusable (Section 6.5). |
tenantRef
|
REQUIRED | The tenant under which the subscriber is provisioned. MUST be within the credential's bound tenant set. |
accountRef
|
REQUIRED | The subscriber identity under that tenant. |
actionType
|
REQUIRED | The declared Action Type. Undeclared/unknown types are refused (NO_LAW) — the plan's vocabulary is a whitelist; there is no default. |
estimate
|
RECOMMENDED | Expected consumption (units per unit type). Validated like a report's units: keys MUST be unit types declared by the Action Type, ≤ 16 entries (else MALFORMED_REQUEST). Used to size the opening reservation; a good estimate reduces both refusal risk and over-reservation. |
scope
|
OPTIONAL | An opaque funding-scope label. Funding buckets provisioned with a matching scope become eligible; exclusive-scope buckets fund only matching Actions and never fall through to general funds. Values are plan-defined. |
parentActionId
|
OPTIONAL | Declares this Action a child of an open parent Action of the same subscriber (Section 9). |
gatewayTime
|
OPTIONAL | The gateway's clock at the first attempt — preserved verbatim on retries (it is part of the request identity). Recorded as evidence, never enforced against (Section 15.6). |
The server resolves the subscriber, verifies tenancy (Section 15), selects the price entry for the declared Action Type from the subscriber's plan (pinning the plan version — the Action is rated under that law for its entire life), resolves the applicable time band if the type is banded (Section 10). It then verifies budget headroom (the opening reservation covers the estimated charge, any contractual minimum, and rounding), verifies the shared bounds (any sponsor cap and the applicable capacity pool, Section 12), applies the trust limits (Section 15.4), and answers.¶
| Field | Present | Meaning |
|---|---|---|
decision
|
always |
GRANT or REFUSE. |
grantId
|
every grant | Correlates the lifecycle; on a flat-priced Action it correlates the settled Action. |
grantSeq
|
every grant | Monotonic generation number per Action. A client MUST discard any grant whose grantSeq is lower than one it has already seen for the Action. |
grantIssuedAt
|
every lifecycle grant | The grant's issue instant — replay-stable; the base of the capacity-rate window (Section 12.2). |
grantedUnits
|
lifecycle grants | The granted pool, in abstract pooled units. |
reportAt
|
lifecycle grants | The consumption threshold at which the client MUST report. |
expiresAt
|
lifecycle grants | Grant validity instant (Section 8). |
unitWeights
|
every lifecycle grant (all price forms, including free) | Pool multipliers per unit type — the complete map, constant for the Action's whole life (pinned with the law): consuming quantity q of unit type u draws q × weight(u) from the pool. Weights are positive decimals, scale ≤ 6; each product is rounded HALF_UP at scale 6, and products are summed exactly. Ratios, not prices. |
quantumUnits
|
quantised Action Types | The block size in pooled units: consumption is charged in whole blocks; the client SHOULD expect pool draw-down in block increments. |
timeBandId
|
banded Action Types (including flat) | The time band fixed at opening (Section 10). |
serverTime
|
every response — envelope, always fresh, never replayed | The server clock at this processing — the authoritative time reference (Section 8). |
actionLive, currentGrantSeq, currentGrantId
|
every Action-identifying 200 — envelope, always fresh | The live truth: whether the Action is open (monotonic — once false, never true), the current generation, and the current grant's opaque address (currentGrantId — the recovery handle: a restarted client reports against it). On any response, replays included, the client MUST obey the envelope over the stored body: actionLive: false means stop; currentGrantSeq above a replayed grant's grantSeq means that grant is stale. |
lawVersion
|
every lifecycle grant | The pinned plan/price-book version (integer) this Action rates under — for client-side correlation with the tenant's published catalogue; opaque otherwise. |
limits
|
every lifecycle grant | The enforceable caps: maxCallsPerAction (int, always), maxRuntimeSec (int, always — plan value or platform default; measured from the Action's opening grantIssuedAt, exceeded when now > open + maxRuntimeSec; also bounds heartbeat-only renewal), maxConcurrentActions (int, always — subscriber-level), signaturesRequired (bool, where configured), minRate (decimal string, pooled units per second, where a capacity pool applies), enforcementWindowSec (int, where a capacity pool applies — the window over which consumed rate is accounted, Section 12.2). Disclosed so a gateway can plan work instead of discovering limits through stops. |
rateReduced
|
when capacity pressure reduced this grant (opening or continuation) |
true, with reduceCause: "CAPACITY" (Section 12.3). |
settled
|
flat-priced Actions |
true: charged at open; no reports, no validity. |
Grant invariants (normative): every issued grant satisfies grantedUnits > 0,
0 < reportAt ≤ grantedUnits, quantumUnits > 0 where present,
expiresAt > grantIssuedAt, and reachability: reportAt MUST be attainable within the
Action's cumulative caps under the grant's weights. A response violating them is a server
defect; the client treats it as a 5xx-equivalent and retries the identical request.¶
The client MAY serve Calls until the earliest of: pool exhaustion, the cycle delta reaching
reportAt (≥, Section 7.7), or expiresAt. It MUST report at reportAt; it MUST NOT knowingly
serve consumption beyond the granted pool other than the tail defined in Section 3.4.¶
refusalReason registry (version 1):¶
| Reason | Meaning |
|---|---|
NO_HEADROOM
|
The opening reservation cannot be fully funded. Partial funding is never granted. |
CEILING_PREEMPT
|
The Action's ceiling (or its family's, Section 9) leaves no room. |
ACCOUNT_VELOCITY
|
Account-level velocity limit engaged. |
BALANCE_VELOCITY
|
Balance-level velocity limit engaged. |
GROUP_VELOCITY
|
Group-level velocity limit engaged: a rate bound across a declared group of related accounts. Version 1.1 (Section 5). |
MAX_CONCURRENT_ACTIONS
|
The subscriber's concurrent-Action cap is reached. |
PARENT_STOPPED
|
The declared parent Action is not open. |
NO_LAW
|
No applicable price entry: unknown Action Type, no plan, or unknown identities on a declared tenant. |
DRAINING
|
The service is draining (an operator-controlled, temporary state): new Actions are refused. Always retryable, with retryAfter (Section 11). |
BLOCKED_TARIFF
|
The plan blocks this Action Type for this subscriber. |
SERVICE_BARRED
|
The subscriber's state bars service. |
DUPLICATE_ACTION
|
The actionId belongs to an opened Action and the request identity differs (Section 6.5). |
EXPOSURE_CAP
|
A sponsor's aggregate bound across its population is reached; limitOwner names the sponsor (Section 12.1). |
NO_CAPACITY
|
The applicable capacity pool cannot admit the Action at or above the declared minimum rate (Section 12.2). Always retryable, with retryAfter. |
PREEMPTED
|
A capacity commitment was reclaimed under the declared pressure policy before the grant could stand (Section 12.2). Always retryable. |
CLASS_BLOCKED
|
Reserved; never emitted in version 1. |
Every refusal additionally carries:¶
| Field | Present | Meaning |
|---|---|---|
retryable
|
always on refuse | Whether the identical request MAY succeed later with nothing changed but time. Version-1 retryable reasons — exactly seven: the transient-scarcity six (NO_CAPACITY, PREEMPTED, ACCOUNT_VELOCITY, BALANCE_VELOCITY, GROUP_VELOCITY, MAX_CONCURRENT_ACTIONS) plus DRAINING. Every other reason is retryable: false. |
retryAfter
|
REQUIRED when retryable: true
|
Seconds to wait before retrying the identical request. |
grade
|
always on refuse | The evidence behind the reason: PROVEN (the server's own arithmetic on its own records), ATTESTED (an operator process decided), or BOUNDED (true within a stated window, no stronger). A client MUST NOT present more certainty to the end user than the grade holds. |
limitOwner
|
on a party-scoped bound | Names whose limit was reached (e.g. the sponsor's), so an end user is never shown a generic "insufficient funds" for someone else's cap. |
automatedDecision
|
always on refuse, from version 1.1 (Section 5) |
true in version 1: the refusal was decided by machine, with no human in the loop (Section 15.7). Carried on the decision itself so the notice travels with the refusal it describes. |
reviewContact
|
when the tenant configures one, from version 1.1 (Section 5) | A human-review channel — a URI or plain-text contact — through which the refused party can have the decision re-examined by a person. Display text under the limitOwner presentation rules (printable characters only, rendered as plain text, never markup); tenant-configured and echoed by the server. A client SHOULD present it alongside the refusal it accompanies. |
Version-1 grades: NO_CAPACITY and PREEMPTED are BOUNDED; SERVICE_BARRED and
DRAINING are ATTESTED; every other reason is PROVEN.¶
A refusal is a complete outcome: no budget is held, nothing is charged, and the client MUST
NOT serve the work. A refusal retains no identity: no Action opens, the actionId
remains unclaimed, and any later Authorise carrying it — identical or different — is
evaluated fresh. DUPLICATE_ACTION applies only against Actions that actually opened.
Concurrent identical Authorises are serialised per subscriber; if one opens the Action, the
other lands on live-Action replay (Section 6.5).¶
Request identity is SHA-256 over the JSON Canonicalization Scheme (JCS) [RFC8785] canonicalization of the
strict-parsed request body (duplicate or unknown members are rejected before hashing, Section 5);
for Reports, the path grantId is appended to the canonical bytes before hashing. Because
every decimal travels as a canonical string (Section 4.3), JCS needs no number-rendering caveats.
Two requests with equal canonical form are the same request. Test vectors accompany the
conformance suite.¶
Identifier scopes: actionId is unique within its (tenant, account) pair — two tenants
can never collide; callId is unique within its Action; grantId is server-issued, opaque,
and unique within the charging domain. Server recognition retention is the late window (Section 7.6).¶
Authorise always answers in the Authorise shape — a replay re-presents facts, it never returns a stored Report body:¶
Retry of a live Action (same actionId, matching identity) → decision: GRANT
re-presenting the current standing grant (current grantSeq and parameters) plus
its cycle-baseline receipt (acceptedCumulative at the settlement that issued it), so
a restarted gateway recovers both the address and the remaining pool — with a fresh
envelope.¶
Retry of an ended Action within the late window (matching identity) → an Authorise-shaped
terminal acknowledgement: decision: GRANT, settled where applicable, the ending,
endedAt, and the final receipt; a flat-priced Action's retry re-receives
GRANT/settled: true, never a refusal.¶
Same actionId as an opened Action, different identity → 200
REFUSE / DUPLICATE_ACTION (a charging refusal like any other — identity conflicts are
not HTTP errors).¶
An actionId that opened an Action is never legally reusable, including after the Action
ends. Beyond the late window the server no longer guarantees replay; a client MUST NOT
rely on replay after it.¶
Clients MUST serialise a retry as the identical logical request: JCS removes JSON key-order differences, and nothing else — there is no equivalence class of spellings (Section 4.3 admits exactly one spelling of every value; any other is malformed before identity is ever computed). Any byte difference that survives canonicalization is a different request.¶
POST /ocs/ai/v1/grants/{grantId}/reports¶
| Field | Requirement | Meaning |
|---|---|---|
schemaVersion
|
REQUIRED | See Section 5. |
actionId
|
REQUIRED | The Action being reported. |
tenantRef, accountRef
|
REQUIRED | Identity resolution; MUST match the Action's. |
calls[]
|
REQUIRED | Per-call cumulative totals (Section 7.2). MAY be empty: an empty calls[] with final: false is a heartbeat — an ordinary fresh cycle (Section 3.5) that settles whatever unsettled meter delta exists (normally zero) and renews the grant — the lease-renewal mechanism for quiet periods; it passes through the same stop-condition precedence as any cycle. |
final
|
REQUIRED |
true declares this the closing report (the CLOSE ending). |
gatewayTime
|
OPTIONAL | As in Section 6.1 — recorded, never enforced against (Section 15.6). |
Each calls[] entry is {callId, units, startedAt?, signature?} where units is a map of unit type →
cumulative total for that Call since the Call began — not a delta; values are canonical
decimal strings (Section 4.3). calls[] is a keyed patch, not a snapshot: only Calls with
changed totals need appear; an omitted callId reads as unchanged; a callId never seen
before initialises all its declared units at zero baseline; two entries with the same
callId in one request are malformed. The calls-per-Action limit counts distinct
callIds over the Action's life. Unit keys MUST be among those declared by the pinned
Action Type (undeclared keys are malformed); declared keys absent from an entry read as
unchanged. signature is reserved in version 1: an opaque string, recorded verbatim.
Its verification profile is bilateral and out of band, and SIGNATURES_REQUIRED (Section 7.4)
applies only where such a profile is agreed. startedAt (version 1.1, Section 5) is OPTIONAL
evidence: the instant the Call began at the serving system (timestamp, Section 4.3). Like gatewayTime it is recorded
and never enforced against (Section 15.6), and it can never re-price anything — the Action's time
band is pinned at open (Section 10), so no client-reported timestamp changes a charge. Its value is
that the moment consumption occurred, not merely the moment it was reported, survives
into the audit record. It SHOULD carry the same value on every entry for a given callId;
the server retains it under the audit retention rules (Section 15.7).¶
Monotonicity and out-of-order delivery: a reported value lower than the server's stored
high-water for that Call and unit is absorbed: the meter is the per-value maximum, so a
stale, reordered, or replayed report charges zero and is never an error — this is what makes
the arithmetic immune to retries, duplicates, and delivery order. (Within one request there
is no shrinking case to define: duplicate callId entries are malformed outright.)¶
Cumulative reporting is what makes the protocol retry-safe: a replayed report or replayed call entry moves the cumulative meter by zero and therefore charges zero. The server settles each cycle as the increment of the rated cumulative consumption under the pinned law — never rating the same consumption twice, for any price form.¶
Pool arithmetic (normative — one cumulative pipeline, cadence-independent): everything
is computed on Action-cumulative quantities, and every charge or threshold is a delta
of cumulatives — never a rounded delta. Per unit type u:
cumulativePooled_u = HALF_UP₆(weight_u × cumulativeTotal_u); the Action's pooled total is
the exact sum over unit types. For quantised forms, blocks apply to that cumulative pooled
total — blocksCharged = ceil(cumulativePooled / quantumUnits) — and each cycle charges the
newly completed blocks (the cumulative-rated rule). reportAt and pool exhaustion compare
raw (unquantised) pooled-total deltas against the cycle baseline. Because rounding and
quantisation are applied only to cumulative values, report segmentation provably cannot
change any charge.¶
| Directive | Meaning |
|---|---|
CONTINUE
|
Settlement done; the next grant is included (grantId, grantedUnits, reportAt, expiresAt, unitWeights, serverTime, and the other grant fields of Section 6.3). The tail (Section 3.4) is served under the old grant and settled on the next cycle. Under capacity pressure the next grant MAY be reduced (Section 12.3), flagged rateReduced: true. |
STOP
|
Settlement done; stop obligation — the client MUST promptly cease serving the Action's traffic. stopReason per Section 7.4; endedAt states the ending instant (the late window's start). Consumption already in flight is reportable afterwards (Section 7.6 / Section 8). |
SETTLED
|
Final settlement acknowledged. Returned for a final: true close and for every non-replay terminal response of a CLOSE-ended Action (the terminal directive is a function of the ending, Section 3.5: STOP-ended Actions answer STOP, LAPSE-ended answer STOP (LAPSED)); late adjustments are flagged adjustment: true. Carries endedAt. |
CURRENT
|
No settlement occurred (Section 3.5 LIVE row 6): the response to a stale, non-final, unrecognised report — the standing grant re-presented (its full Section 6.3 grant fields) plus baselineCumulative (that grant's cycle baseline) and meterCumulative (the post-merge meter). The client resynchronises: pool draw-down = weighted(meterCumulative − baselineCumulative). |
This distributed-worker convergence flow is illustrative; it does not relax the one-in-flight requirement of Section 3.4. The CURRENT branch shown is the no-ending-condition branch of Section 3.5 LIVE row 6:¶
Gateway worker A Charging Server Gateway worker B
| | |
|--- Report on G1 --->| |
|<-- CONTINUE G2 -----| |
| |<-- stale non-final -|
| | G1; no retained- |
| | id match |
| | max-merge meter; |
| | evaluate ending |
| | conditions |
| |--- CURRENT G2 + --->|
| | baseline + |
| | post-merge meter |
| | no settlement, |
| | no mint |
| | | B resynchronizes
¶
Cycle baseline (normative): grantedUnits and reportAt are cycle-scoped, and the
baseline is a meter state, not an instant: a cycle's consumption is the weighted pooled
delta (Section 7.2 arithmetic) between the accepted meter at the settlement that issued the
grant — the acceptedCumulative of that response; zero for the first grant — and the
current meter. Both sides can compute it exactly: the client from its last receipt, the
server from its stored meter. Report values remain Action-cumulative (Section 7.2). Tail
consumption lands in whichever cycle's report first carries it — the cumulative arithmetic
makes double-counting impossible.¶
Settlement receipt: every settling response (CONTINUE, STOP, SETTLED) carries
settlementId (stable per settlement; a replay returns the same id), acceptedCumulative
(the server's post-merge Action totals — a complete map over the declared unit types),
acceptedCalls (an array of {callId, units} entries — the per-call accepted
cumulative values for exactly the Calls present in this request, ≤ 256 entries), and
cyclePooledUnits (the raw pooled delta settled this cycle, pre-quantisation), plus
where applicable clamped: true (limited by the meter or a ceiling) and adjustment: true
(a late settlement). A client holding several receipts trusts the one from the
highest-generation outcome. Receipts are denominated in units only — no balance, price,
or currency ever crosses this wire.¶
Response replay and convergence: governed entirely by the state machine (Section 3.5) — the
server serialises all of one Action's operations; only a fresh Report on the current grant
runs a cycle and mints; identity-matched retries replay their stored outcome under a fresh
envelope; non-matching reports converge (a final: true close is always honoured against
the current state; non-final ones max-merge and answer CURRENT); retention is the
terminal outcome plus the last 4 non-terminal outcomes — an aged retry converges safely,
though its original settlementId is then no longer re-presentable (Section 7.6). A lost
CONTINUE is therefore always recoverable: retry the identical request (replay), or
report against currentGrantId from any response's envelope. acceptedCalls appears on
Report responses only.¶
The lost-response path below illustrates the request identity and replay rules of Section 6.5 and
this section. R includes the Report body and its path grantId; S is the stable
settlement receipt. The retry remains the same logical Report for Section 3.4:¶
Gateway Charging Server | | |--- Report R against current Gn --->| | | merge, settle, and | | atomically store | | CONTINUE G(n+1) + S |<-- CONTINUE G(n+1) + receipt S --X | response lost | | |--- identical retry of R ---------->| | | retained identity | | matches |<-- stored CONTINUE + receipt S ----| body replay; fresh | | envelope; no | | settlement, no mint¶
stopReason registry (version 1)
| Reason | Meaning |
|---|---|
CEILING_REACHED
|
The Action (or family) ceiling is exhausted. |
NO_HEADROOM
|
Funds cannot cover a further grant. |
CALL_LIMIT
|
The calls-per-Action limit was reached (always enforced; the settlement of the limit-crossing report is completed first). |
MAX_RUNTIME
|
The Action exceeded its maximum runtime. |
INFLATION
|
A consumption-inflation trust wire tripped. |
SIGNATURES_REQUIRED
|
The Action Type requires call signatures and they were absent. |
PARENT_STOPPED
|
The parent Action ended; children are stopped (Section 9). |
ADMIN_FORCED
|
Operator intervention. |
DRAINING
|
The service is draining: settlement completed, no re-grants are issued. |
NO_CAPACITY
|
Capacity fell below the declared minimum serve rate; continuing usefully is impossible, so the server stops honestly rather than degrading past use (Section 12.3). |
EXPOSURE_CAP
|
The sponsor's aggregate bound was exhausted mid-Action; limitOwner names the sponsor (Section 12.1). |
LAPSED
|
The grant's validity lapsed — returned for the grace-window close (Section 7.5), and as the terminal directive of every LAPSE-ended Action's later responses (Section 3.5). |
Precedence (normative): final: true beats every server stop condition → SETTLED
(Section 7.5), draining included. For a non-final report on which several stop conditions hold at
once, the reported stopReason is the first that applies in this order: ADMIN_FORCED ·
DRAINING · PARENT_STOPPED · LAPSED · CEILING_REACHED · EXPOSURE_CAP ·
NO_HEADROOM · CALL_LIMIT · MAX_RUNTIME · INFLATION · SIGNATURES_REQUIRED ·
NO_CAPACITY — and the stored terminal outcome records that same reason, so replays agree.¶
With now being the server clock at processing:¶
now < expiresAt → normal processing; CONTINUE is possible.¶
expiresAt ≤ now < expiresAt + 5 s → the report is accepted as the closing report:
consumption is settled and the directive is SETTLED when final: true, else
STOP (LAPSED) — never CONTINUE.¶
now ≥ expiresAt + 5 s → the Action is ended as LAPSE (if it wasn't already — the
transition instant is the same either way, Section 3.5) and the report is processed as a late
report (Section 7.6).¶
The following diagram illustrates those branches; Section 3.5 and the rules above remain authoritative, including the stored-outcome replay exception:¶
Gateway Charging Server (`now`) | | |--- Report ------------------------>| | +-- now < expiresAt | | normal processing; | | CONTINUE is possible | | | +-- expiresAt <= now | | < expiresAt + 5 s | | final=true: SETTLED | | final=false: | | STOP (LAPSED) | | | `-- now >= expiresAt + 5 s | ENDED(LAPSE), with | endedAt=expiresAt+5 s; |<-- response under selected branch -- late handling | (section 7.6)¶
Precedence: on a final: true report, the client's close wins over any concurrent
server stop condition — the response is SETTLED (the stop obligation is moot once the
client has ended the Action). A late report can never revive a grant. A client that wants to
continue past expiresAt MUST have reported before it.¶
The LAPSE instant (normative): an Action that ends as LAPSE has
endedAt = expiresAt + 5 s — by definition, whether the ending is physically performed
by a timer, a sweep, or the first post-grace report. Both sides can compute the late
window's anchor from the grant alone. CLOSE and STOP endings record their processing
instant as endedAt.¶
While recognised — until max(endedAt + 24 h, expiresAt + 1 h) (Section 3.5; endedAt is
carried on every terminal response) — the server:¶
MUST retain recognition: replay of stored responses to recognised retries (matching
request identity) and duplicate-actionId detection are unconditional for the full
window — this guarantee does not degrade.¶
Settles genuinely new consumption reported late: the cumulative meters are merged
upward, and the increment of rated consumption — floored at zero, clamped by the remaining
ceiling — is charged under the Action's pinned law and returned with the ending's
terminal directive (Section 3.5), adjustment: true, and a receipt (Section 7.3). Contractual minimums are never applied a second
time. Repeated late reports charge each unit at most once. Late-settlement capability
(unlike recognition) is best-effort under extreme retention pressure — an exceptional,
server-metered condition; a late report the server recognises but can no longer settle is
answered with the stored terminal outcome plus unattributable: true and no new
settlementId — the response says plainly that the new units were not settled (they
are recorded unattributable). A non-matching terminal report whose merge adds zero
receives the terminal outcome with the current receipt and no adjustment flag.¶
Beyond the late window, reports against unknown identities receive 404 and the traffic is recorded as unattributable — recorded, never charged.¶
A client MUST report when the cycle's pooled consumption delta reaches reportAt
(comparator: ≥), and SHOULD report
promptly on its own initiative when an Action ends client-side (with final: true). Batching
beyond reportAt increases the client's overshoot exposure, which the protocol bounds to one
final report but does not eliminate.¶
On receiving STOP, the client MUST stop admitting new Calls immediately, SHOULD cancel cancellable in-flight work, and MUST flush the final meter — dispatching the first flush report within 30 seconds (a hard per-event deadline, distinct from the statistical promptness profile of Section 12.4); when more than 256 Calls changed, the flush spans serialised 256-call chunks continued back-to-back. The flush obligation fires once per ending: a STOP directive received in response to the flush itself, a replay, or a late response does not re-trigger it. Uncancellable work may complete, and its consumption reports late (Section 7.6).¶
| Instant | Source | Effect |
|---|---|---|
reportAt
|
grant | Consumption threshold: the client MUST report on crossing it. |
expiresAt
|
grant | Grant validity end. Reports within a 5 s grace settle as closing; beyond it the Action ends as LAPSE. |
max(endedAt + 24 h, expiresAt + 1 h)
|
terminal response + last grant | Late-window end: recognition unconditional until it; late consumption settleable within it (Section 7.6). |
All boundaries are evaluated on the server clock. Every response carries serverTime
(Section 6.3), the authoritative reference: the client SHOULD track the offset between serverTime
and its own clock plus the measured round-trip, and dispatch its pre-lapse report with at
least that margin in hand — a report sent "before the lapse" by the client's clock but
processed after it lands in the closing or late branch.¶
This table is a descriptive index to the defining prose, not a second source of rules. Where a value is a recommendation or a configured value, the table says so; where two statements are not fully harmonised, it preserves both without choosing between them. "Deployment-configurable" includes plan-, catalogue-, integration-, client-, and server-policy values.¶
Fixed in protocol.¶
| Name | Value | Defining section |
|---|---|---|
| Protocol generation | Version 1 (/v1/) |
Section 1.4, Section 4.1, Section 5 |
| Charging operation count | Exactly two: Authorise and Report | Section 3.2 |
| Authorise endpoint |
POST /ocs/ai/v1/authorizations
|
Section 4.1 |
| Report endpoint |
POST /ocs/ai/v1/grants/{grantId}/reports
|
Section 4.1 |
| Media and transport |
application/json over HTTPS; plaintext refused |
Section 4.1 |
| Request authentication | A per-gateway bearer API key in every request; OAuth2 is not in version 1 | Section 4.2 |
| Redirect posture | HTTP 3xx is not used | Section 1.4 |
| Charging-domain topology | One logical charging domain per tenant in version 1 | Section 1.4 |
| Action lifecycle cardinality | One successful Authorise; one ending | Section 3.1 |
| Current grant cardinality | Exactly one per live Action | Section 2 |
| Outstanding Report cardinality | At most one logical Report per Action | Section 3.4 |
| Opening and mint generations | Lifecycle opening is G1; each mint is exactly +1; a flat terminal envelope is generation 2 | Section 2, Section 3.3, Section 3.5 |
| Outcome retention ring | Terminal outcome plus the last 4 non-terminal outcomes; cycles and late adjustments share the ring | Section 3.5, Section 7.3 |
| Lapse grace | 5 s | Section 3.5, Section 7.5, Section 8 |
| LAPSE instant |
endedAt = expiresAt + 5 s
|
Section 7.5 |
| Recognition / late window | Until max(endedAt + 24 h, expiresAt + 1 h)
|
Section 2, Section 3.5, Section 7.6, Section 8 |
| Decimal scale and rendering | Canonical decimal JSON string; scale at most 6; the lexical exclusions are in Section 4.3 | Section 4.3 |
| Request-quantity domain |
[0, 10^9], scale at most 6 |
Section 4.3 |
| Per-call and Action/unit parser caps | Each is at most 10^9
|
Section 4.4 |
| Per-call operational segmentation point | Terminate or segment before a Call's cumulative value reaches 10^9
|
Section 4.3 |
| Weight domain |
[0, 10^6], scale at most 6; issued weights are positive |
Section 4.3, Section 6.3 |
| Derived pooled domain |
[0, 10^17), scale at most 6, for the fields named in Section 4.3
|
Section 4.3 |
| Identifier syntax | At most 128 characters from [A-Za-z0-9._:-]
|
Section 4.3 |
| Display-label syntax | At most 64 printable UTF-8 characters; no control or bidirectional-control characters | Section 4.3 |
| Unit-type syntax |
[a-z0-9_]{1,64}
|
Section 4.3 |
| Timestamp profile |
YYYY-MM-DDThh:mm:ss.sssZ; 3 fractional digits; years 1970-9999; no leap seconds |
Section 4.3 |
| Count/version integer domain | JSON integer 0 <= n < 2^53; schemaVersion >= 1
|
Section 4.3, Section 5 |
| Compatible schema support | Every version from 1 through maxSchemaVersion
|
Section 5 |
| Request-body limit | At most 256 KB; the memo does not define the KB base | Section 4.4 |
| Report patch size | At most 256 calls[] entries |
Section 4.4 |
| Unit-map size | At most 16 entries per Call; estimate and response maps share the 16-unit bound |
Section 4.3, Section 4.4, Section 6.1 |
| Receipt call count |
acceptedCalls has at most 256 entries and covers exactly the Calls in the settling Report |
Section 4.3, Section 7.3 |
| Grant inequalities |
grantedUnits > 0; 0 < reportAt <= grantedUnits; quantumUnits > 0 where present; expiresAt > grantIssuedAt
|
Section 6.3 |
| Pooled product rounding |
HALF_UP at scale 6 per cumulative unit-type product; exact sum |
Section 6.3, Section 7.2 |
| Quantised rating |
ceil(cumulativePooled / quantumUnits); charge block deltas |
Section 7.2 |
| Threshold and exhaustion basis | Raw, unquantised pooled-total delta from the cycle baseline | Section 7.2 |
| Report threshold comparator | Dispatch when the cycle delta is >= reportAt
|
Section 7.7 |
| Threshold / closing cadence | Section 7.7 requires threshold dispatch and recommends a prompt client close; Section 12.4 says both are due immediately outside the profile bound | Section 7.7, Section 12.4 |
| STOP flush | First chunk within 30 s, once per ending; chunks of at most 256 changed Calls back-to-back | Section 7.7, Section 14 |
| Retry backoff recommendation | Initial 1 s, factor 2, full jitter, cap 30 s (recommendation) | Section 13.3 |
| Client attempt deadline recommendation | 2 s per attempt (recommendation) | Section 13.5 |
| TLS versions | TLS 1.2 or later required; TLS 1.3 recommended; [RFC9325] configuration | Section 4.1, Section 15.1 |
| Request-identity algorithm | SHA-256 over JCS [RFC8785]; append path grantId for Report |
Section 6.5 |
| Explicit HTTP status mappings | 200, 400, 401, 403, 404, 405, 415, 429, and 503 as mapped | Section 3.5, Section 4.1, Section 13.1 |
| Additional server-error class |
INTERNAL is status-mirroring; retry guidance covers 5xx generally, whose exact statuses are not enumerated |
Section 13.1, Section 13.3 |
| HTTP retry header |
Retry-After on 429 and 503 |
Section 13.1 |
| HTTP connection posture | HTTP/2 connection reuse is assumed (operational assumption) | Section 13.5 |
| Current protocol version | 1.1 — generation 1, wire schemaVersion 2; each minor binds to one schemaVersion
|
Section 5 |
| Retryable refusal set | Exactly seven reasons from version 1.1 (six in 1.0), enumerated in Section 6.4 | Section 5, Section 6.4 |
| Closed per-version value sets | Decision, refusal reason, stop reason, error code, ending, directive, grade, and reduce cause; initial values in Section 17; additions bind to minor versions per Section 5's version table | Section 5, Section 17 |
| API-key rotation and revocation posture | Dual-active rotation; revocation effective "within seconds" | Section 4.2, Section 15.1 |
Deployment-configurable.¶
| Name | Value | Defining section |
|---|---|---|
Refusal human-review channel (reviewContact) |
Tenant-configured; absent unless configured | Section 6.4, Section 15.7 |
| Sponsor-cap accounting window | Part of the sponsor's declaration (credits per period); boundary mechanics deployment-profile in version 1 | Section 12.1 |
maxSchemaVersion
|
Highest compatible schema supported; 1 <= n < 2^53
|
Section 4.3, Section 5 |
grantedUnits
|
Server-selected per cycle within the pooled domain and grant inequalities | Section 4.3, Section 6.3, Section 12.2 to Section 12.3 |
reportAt
|
Server-selected per cycle; 0 < reportAt <= grantedUnits
|
Section 6.3, Section 7.7 |
Grant validity / expiresAt
|
Server-selected instant after grantIssuedAt; no default duration stated |
Section 6.3, Section 8, Section 12.3 |
| Action-Type vocabulary | Catalogue-defined, whitelisted, and distributed out of band | Section 1.4, Section 6.1 |
unitWeights
|
Positive plan/law ratios, at most 10^6, pinned for the Action |
Section 4.3, Section 6.3 |
Price form and lawVersion
|
Catalogue/plan choice; the selected law version is pinned | Section 3.3, Section 6.2 to Section 6.3 |
Quantisation / quantumUnits
|
Action-Type choice; positive pooled decimal where used | Section 6.3, Section 7.2 |
Time-band schedule / timeBandId
|
Tenant-published full-week schedule; band selected once at opening | Section 6.3, Section 10 |
| Action or family ceiling | Plan-defined internal ceiling; no monetary value appears on the wire | Section 2, Section 6.2, Section 9, Section 15.7 |
| Contractual minimum | Plan-defined; applied as described at open and not a second time late | Section 6.2, Section 7.6 |
| Funding scope and buckets | Plan-defined scope, eligibility, and exclusivity |
Section 6.1 |
limits.maxCallsPerAction
|
Always disclosed; integer in the count domain; counts distinct Calls over the Action | Section 4.3, Section 6.3, Section 7.2 |
limits.maxRuntimeSec
|
Section 6.3 says always disclosed as a plan value or platform default and exceeded when now > open + value; Section 15.4 calls maximum runtime optional per deployment/plan |
Section 4.3, Section 6.3, Section 15.4 |
limits.maxConcurrentActions
|
Always disclosed subscriber-level integer cap | Section 4.3, Section 6.3, Section 15.4 |
limits.signaturesRequired
|
Boolean where a bilateral signature profile is configured | Section 6.3, Section 7.2, Section 15.4 |
| Call-signature profile | Bilateral and out of band; no field-specific size cap is stated | Section 1.4, Section 7.2 |
limits.minRate
|
Capacity-only pooled units per second; exact upper numeric domain is not stated | Section 4.3, Section 6.3, Section 12.2 to Section 12.3 |
limits.enforcementWindowSec
|
Capacity-only integer seconds, greater than 0; exact algorithm provisional | Section 4.3, Section 6.3, Section 12.2 |
| Sponsor cap and owner label | Sponsor-defined aggregate internal bound and presentation-safe label | Section 12.1, Section 15.7 |
| Capacity pool | Tenant-defined provisioned rate and named Action-Type membership | Section 12.2 |
| Capacity pressure / reclamation policy | Deployment-declared; no numeric default stated | Section 6.4, Section 12.2 to Section 12.3 |
| Integration promptness bound | Agreed per integration; provisional version-1 default is 30 s at p95 | Section 12.4 |
Refusal-body retryAfter
|
Server-selected integer seconds, required when retryable: true
|
Section 4.3, Section 6.4 |
| Error-envelope retry members |
retryable and retryAfter are independently optional; no status coupling or numeric default is stated |
Section 13.1 |
HTTP Retry-After value |
Server-selected on 429 and 503; Pulse states no minimum or maximum | Section 13.1 |
| Client pre-lapse margin | Measured server-clock offset plus round-trip margin; no fixed value | Section 8 |
| Velocity limits | Optional account/balance controls; thresholds and windows not stated | Section 6.4, Section 15.4 |
| Consumption-inflation wire | Optional control; threshold/window not stated | Section 7.4, Section 15.4 |
| Per-credential API rate limit | Token-bucket rate/capacity; no numeric default stated | Section 13.1, Section 15.5 |
| Draining state | Operator-controlled temporary state; delay returned in retryAfter
|
Section 6.4, Section 11 |
| Mutual TLS | Optional deployment choice | Section 4.2 |
| Credential tenant set | One or more bound tenants; exact cardinality not stated | Section 2, Section 15.2 |
| API-key material and exact revocation SLA | Deployment credential configuration; no exact seconds bound is stated | Section 4.2, Section 15.1 |
| Persistent clock-skew tolerance | Integration trust threshold; no value stated | Section 15.6 |
Audit, gatewayTime, and startedAt retention |
Operator retention policy, apart from the fixed late window | Section 15.7 |
No row defines an Action boundary: the protocol does not define what constitutes one
Action — the actionId is asserted by the application originating the work (Section 6.1). No
row exposes a monetary amount (Section 15.7).¶
Where enabled, an Authorise MAY declare parentActionId, nesting the new Action under an
open parent of the same subscriber, opened by a credential of the same owning
gateway — a parentActionId referencing another gateway's Action is refused 403 before
any charging evaluation (Section 15.3). Children share the parent's family ceiling: sibling
headroom reflects every family settlement as it happens. Any parent ending cascades: open
children are stopped (PARENT_STOPPED) before the parent concludes.¶
Cascade timing (normative): children are stopped server-side at the parent's ending
instant — the server serialises all of one subscriber's operations, so a concurrent parent
ending and child report have a definite order. A stopped child's grant is no longer eligible
for CONTINUE; the child gateway learns at its next exchange, whose response is
STOP (PARENT_STOPPED) after settling the reported consumption. Consumption already in
flight when the cascade lands remains reportable and settleable under the late rules; the
notification gap is bounded by the child's grant validity, exactly like any other silent
period. Late child consumption continues to settle against the family ceiling during the
late window.¶
The following CLOSE example illustrates the cascade for any parent ending. It also shows that version 1 has no server push: the child gateway learns the stop at its next exchange.¶
Parent gateway Charging Server Child gateway
| | |
|--- final Report --->| |
| | serialize subscriber state |
| | stop open child at parent's |
| | ending instant: PARENT_STOPPED|
| | conclude parent |
|<-- SETTLED ---------| |
| | (no server push) |
| |<----- child's next Report ----|
| | merge, settle reported units |
| |--- STOP (PARENT_STOPPED) ---->|
¶
A banded Action Type prices differently by time of day/week, per the tenant's published schedule. Normative rules:¶
The band is resolved once, at authorisation, on the server clock — never from any
client-asserted time — and disclosed as timeBandId in the response.¶
The entire Action rates in its opening band: the band never switches mid-Action, however long the Action runs or when its reports arrive. (Deliberate divergence from telecom voice charging; exposure is bounded by grant validity and maximum runtime.)¶
Published schedules always cover every instant (the platform validates full-week coverage before publication), so band resolution cannot fail.¶
When the service is draining (operator-controlled, temporary): new Actions are refused
DRAINING (retryable, with retryAfter); replays of live Actions are still answered; every
non-final report on a live Action settles normally and answers STOP (DRAINING) — no
re-grants are issued — while a final: true report settles as SETTLED per Section 7.5's
precedence. Terminal semantics (Section 7.6) remain in force until no live state remains.¶
The bounds in Section 6 to Section 7 are per subscriber. Two further bounds range over populations, and both surface on the wire only through the vocabulary above — no new operations, no new fields beyond the refusal metadata of Section 6.4.¶
A sponsoring party (typically a reseller funding an AI benefit) MAY declare an aggregate
spend bound across a population of subscribers. It is enforced at authorisation and at each
cycle like any other headroom check. Its refusals and stops are attributed:
EXPOSURE_CAP always carries limitOwner, and a client MUST surface the distinction to the
end user — from a single account's viewpoint the sponsor's cap and the user's own limit are
otherwise indistinguishable, and telling a user their own money ran out when the sponsor's
did is untrue.¶
The cap's accounting basis is exposure — credits committed to open grants plus credits settled, per sponsor relation; an open grant is spend the sponsor is already standing behind, so a cap that counted settled spend alone could be overshot by in-flight work. The bound is declared per period: the accounting window (a calendar month, a rolling interval) is part of the sponsor's declaration, not a protocol constant, and is disclosed to the sponsor and its members out of band — the wire carries only the refusals and stops the cap produces. Exact period-boundary mechanics (the reset instant, the treatment of exposure spanning the boundary) are deployment-profile in version 1, pending the same trace validation as Section 12.2 to Section 12.4.¶
A tenant MAY declare provisioned serving capacity — a rate — that named Action Types draw
on. Admission then answers two questions: may this be paid for, and can it be served. A
grant is a rate authorisation: its rate is grantedUnits over the interval from
grantIssuedAt to expiresAt — so capacity enforcement changes grant sizing, not the
message set. When a pool cannot admit at or above the declared minimum rate, authorisation
refuses NO_CAPACITY — always retryable with retryAfter — graded BOUNDED, because
capacity keeps no ledger. Capacity refusals say something about the service's provisioned
room, never about the customer's money; clients MUST NOT render them as balance problems.¶
Enforcement is server-side and window-aware: when sizing each grant, the server accounts
the Action's consumed rate over the enforcement window (enforcementWindowSec > 0,
disclosed in limits) — so reporting early and cycling fast confers no rate advantage (the
window remembers, and smaller grants follow). The client has no pacing obligation;
pacing to the grant rate is optional smoothness, not conformance. Shared bounds are
linearizable: admissions against a capacity pool — and against a sponsor cap (Section 12.1) —
are evaluated as if serially against the aggregate; two concurrent admissions can never
both observe and consume the same remaining headroom. (Provisional: the window's exact
algorithm and reproducibility are completed by the capacity conformance traces — see
Status.)¶
Under capacity pressure the server degrades before it refuses: a grant (opening or
continuation) MAY be reduced — always by lowering grantedUnits, never by shortening the
validity window (a shorter window with the same units would raise the implied rate) —
and never below the declared minimum rate. Such a response carries rateReduced: true and
reduceCause: "CAPACITY"; the client can compute the allowed rate exactly from
grantedUnits, grantIssuedAt, and expiresAt. Three guarantees travel with it:¶
The price never changes with the rate. A slowdown is never a surcharge.¶
The cause is always capacity. Commercial throttling has no code path in this protocol — a margin decision is a tariff term to be declared in the plan, never an operational throttle.¶
It is disclosed. The reduction is flagged on the wire and recorded per party, so "what was reduced, when, and why" is answerable per customer, not only per pool.¶
Degradation lands at exchange boundaries only — there is no push channel — which is one
reason validity windows are short. Below the minimum rate the server answers honestly
instead: STOP (NO_CAPACITY) at a report boundary, refusal at authorisation.¶
Rate enforcement is defeated by stale reports: a gateway batching every thirty seconds defeats a per-second pool while passing every other check. Promptness — measured as the elapsed time from a unit of consumption occurring to dispatch of the report that first carries it, evaluated at the 95th percentile — is a tested dimension of an integration's profile (alongside authorise-before-serving and stop obedience): a bound the conformance suite measures, never a documentation claim. The bound is agreed per integration at onboarding; the version-1 default profile bound is 30 s at p95 (provisional — subject to ratification with the rate-enforcement window design; integrations should treat the mechanism as fixed and the number as negotiable). Threshold-triggered reports (Section 7.7) and the closing report are due immediately regardless of the profile bound.¶
| Status | Meaning |
|---|---|
| 200 | Charging outcome — including refusals and stop obligations; inspect decision/directive. |
| 400 | Malformed or out-of-limit request; machine errorCode included (Section 13.2). |
| 401 | Missing/invalid credential. |
| 403 | Credential valid but not authorised: tenant not in the bound set, the Action belongs to another gateway, or a parentActionId references another gateway's Action (Section 9, Section 15.3). |
| 404 | Unknown or mismatched identity: an actionId/grantId the server does not recognise (never opened, or past the late window, Section 7.6), a grantId that does not belong to the body's actionId, or tenantRef/accountRef that do not match the Action's — traffic is unattributable. |
| 405 / 415 | Wrong method / wrong content type (Section 4.1). |
| 429 | Per-credential rate limit exceeded (Section 15.5). MUST carry Retry-After. When rate-limited, the client prioritises mandatory reports (threshold crossings, STOP flushes, pre-lapse) over new Authorises. |
503 + Retry-After
|
The server's own condition — transient contention or overload — on its own channel. Overload is never expressed as a refusal reason: no decision about the caller was reached, nothing is charged, and a shed request is not counted as a refusal. The client SHOULD retry after the indicated delay with the identical request. |
Charging refusals are not HTTP errors: a REFUSE or STOP arrives as 200 — including
DUPLICATE_ACTION (an identity conflict is a charging refusal, not an HTTP error). Clients
MUST NOT treat 200 as permission without inspecting the body.¶
Error envelope: every non-200 response carries a JSON body
{errorCode, message, serverTime, maxSchemaVersion, retryable?, retryAfter?} — errorCode from Section 13.2
for the 400 family or a status-mirroring code otherwise (UNAUTHORIZED, FORBIDDEN,
NOT_FOUND, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA, RATE_LIMITED, UNAVAILABLE,
INTERNAL); schemaVersion is echoed when it was parseable (Section 5). Error envelopes carry no
Action-state fields (Section 2, Envelope).¶
Atomicity (normative): request processing is all-or-nothing. Full validation precedes any state mutation, and every non-200 response is state-neutral — nothing merged, counted, or settled. Settlement and the stored exchange outcome persist atomically; a crash between processing and durable storage means the request was not processed, and the identical retry is safe.¶
errorCode registry (400 family, version 1)
| Code | Meaning |
|---|---|
UNSUPPORTED_SCHEMA
|
Unknown schemaVersion, or fields beyond the declared version. |
LIMIT_EXCEEDED
|
A Section 4.4 limit violated. |
MALFORMED_REPORT
|
Bad unit value, undeclared unit key, duplicate callId entries, duplicate JSON keys. |
MALFORMED_REQUEST
|
Any other syntactic/semantic malformation. |
Idempotency (Section 6.5, Section 7.2) makes retries safe: on timeout or 5xx, the client SHOULD retry the
identical request with bounded, jittered exponential backoff (RECOMMENDED: initial 1 s,
factor 2, full jitter, cap 30 s). The serialisation unit of Section 3.4 is the logical Report:
identical retries are the same unit, and overlapping identical attempts (a retry racing its
timed-out original) are permitted — replay makes them harmless. The admission freeze ends
on a charging outcome (any 200); state-neutral errors (4xx/429/503) leave it in place while
retries continue, until an outcome arrives or the grant expires. The client MUST NOT mint a fresh actionId to retry an
Action whose authorisation outcome is unknown — that risks double authorisation; retry the
original request instead, or abandon the actionId permanently. Retrying a Report remains
useful up to the late window; retrying an Authorise past the point the work would still be
served is pointless — abandon it; if the work is re-attempted later, that is a new Action.¶
When the server is slow or unreachable, the existing mechanisms compose into a complete, bounded posture; a conforming gateway follows it:¶
Mid-Action: per Section 3.4's single-writer rule, once the report is dispatched no new Calls are admitted until a response arrives — retrying included. Work already begun (the tail) completes within the remaining pool of the last received grant; consumption from uncancellable streams is reported when connectivity returns (late, if need be — the late window exists for this).¶
At pool exhaustion with no response: stop admitting new Calls. Serving beyond the pool is non-conformant regardless of the outage.¶
At expiresAt with no successful report: the gateway MUST stop serving the Action —
validity is the hard bound, and the server will settle at the reported meter via the
lapse ending. Consumption reported late still settles while late-settlement capability
holds (Section 7.6). Its exceptional degradation is the one bounded exception, and it is
flagged unattributable, never silent.¶
New Actions: when Authorise is unreachable, a conforming gateway does not serve — fail-closed is the only conformance posture this protocol defines. (Informative: a tenant may contractually accept unauthorized serving during outages; such traffic is outside this protocol — never authorised, never reported, never settled here — and is accounted between the parties as declared loss. Pulse neither forbids the business arrangement nor provides a wire path for it.)¶
Recovery: retry identical requests (Section 6.5/Section 7.3 replay makes every lost response
recoverable), or report against the envelope's currentGrantId; reconcile via receipts
(settlementId, acceptedCumulative, acceptedCalls).¶
This post-grace, late-settleable example illustrates the end-to-end posture. The gateway's
local hard bounds remain the received grant's remaining pool and expiresAt; new Authorises
fail closed during the outage. Other recovery branches remain as specified above.¶
Gateway Charging Server | | | outage begins; a grant is live | | serve only within remaining pool | |--- Report R at reportAt --------X | unreachable | no new Calls; the tail finishes | |--- identical retries of R ------X | | at expiresAt: stop serving | | | | connectivity recovers at | | now >= expiresAt + 5 s | |--- identical retry of R ---------->| | | LAPSE and late | | handling |<-- STOP (LAPSED) + receipt --------| | | |--- final-meter flush ------------->| within 30 s of | (tail included) | STOP |<-- STOP (LAPSED) + receipt --------|¶
The exposure of the outage posture is: the remaining granted pool, plus the consumption
of Calls already in flight when the outage began — each such Call bounded by the
call-level caps (Section 4.4 magnitudes; limits.maxRuntimeSec) and settling late. Uncancellable
in-flight work is the one component the pool does not cap; the protocol bounds it per Call
and prices it under the pinned law, never silently.¶
Authorise and Report sit on the serving hot path; the server is designed to answer from hot
state and the RECOMMENDED client deadline is 2 s per attempt. For latency-critical cheap
actions, the flat price form (Section 3.3) is the minimal-overhead shape (one exchange, no
lifecycle). Control-plane request rate scales with Actions × cycles; the server controls the
cycle rate via reportAt/grantedUnits sizing, and HTTP/2 connection reuse is assumed.
Pre-grants, bulk authorisation, and multi-Action batch reporting are deferred, named
(Section 1.4).¶
Send canonical, versioned, within-limits bodies (Section 4, Section 5).¶
Never serve lifecycle work without a live grant (a flat Action's work is authorised by its settled GRANT itself, Section 3.3); honour stop obligations promptly (Section 3.2).¶
Report cumulatively, never by delta; never shrink a cumulative (Section 7.2).¶
Report on reportAt crossing (MUST) and SHOULD report promptly at client-side endings
with final: true (Section 7.7).¶
Retry with canonically identical requests; never reuse an actionId (Section 6.5, Section 13.3).¶
Never knowingly exceed the granted pool; admit Calls against it using unitWeights
(Section 3.4), and follow the Section 13.4 posture when the charging plane is unreachable.¶
Treat the server clock as authoritative for lapse and bands (Section 3.4, Section 10).¶
Report promptly — within the integration's tested promptness bound (Section 12.4); batched, stale reports defeat rate enforcement and downgrade the integration's profile.¶
Render refusals honestly: no more certainty than grade holds, capacity refusals never
as balance problems, and attributed limits (limitOwner) never as the user's own (Section 6.4,
Section 12).¶
Obey the envelope over any stored body: discard grants with a stale grantSeq, treat
actionLive: false as a stop, and converge on currentGrantSeq (Section 2, Section 6.3, Section 7.3).¶
On STOP: admit nothing new, cancel what is cancellable, and flush the final meter — first chunk within 30 seconds, once per ending (Section 7.7).¶
One in-flight Report per Action, and no new Call admissions while one is outstanding (Section 3.4).¶
TLS 1.2+ is REQUIRED (1.3 RECOMMENDED; [RFC9325] configuration). API keys are secrets: gateways MUST store them in a secrets facility, MUST NOT place them in URLs, and MUST redact them from logs and traces; the server never echoes a key and audits by credential id only. Dual-active rotation rolls keys without downtime. Compromise of a key is bounded by revocation (effective within seconds), tenant binding, gateway ownership, and the per-credential rate limit. Revocation and outstanding grants: a revoked credential's requests fail 401 from the revocation instant; Actions its gateway opened continue only to their grants' lapse, ending and settling at their reported meters — the residual exposure is the remaining validities. Because ownership is gateway-level, a replacement credential of the same gateway MAY report and close them; nothing strands.¶
Every request is authorised as: credential enabled → tenantRef within the credential's
bound set (else 403) → subscriber belongs to that tenant (else NO_LAW). A credential can
never operate on another tenant's subscribers.¶
Every Action records the opening credential's gateway. Reports and Authorise-replays are accepted only from credentials of that gateway (else 403) — a sibling gateway of the same tenant cannot operate another gateway's Actions, and a child Action MUST be opened by the parent's owning gateway (Section 9; checked before any charging evaluation). Identifiers are correlators, not secrets; authority derives from credential + binding + ownership, so sequential or guessable ids confer no authority. Acknowledged residual: the 403-vs-404 distinction lets an authenticated gateway of the same tenant probe whether an identifier exists — existence only, never content or authority; accepted and documented.¶
Always enforced per subscriber: a concurrent-Actions cap and a calls-per-Action limit. Optionally (per deployment/plan): velocity limits, consumption-inflation wires, maximum runtime, and required call signatures. These stop Actions; only operator action bars a subscriber.¶
A per-credential request rate limit (token bucket; 429 on excess) protects the platform independently of charging-level velocity. Every request is audit-recorded by credential id; the key itself never appears in records.¶
Band selection and lapse are computed exclusively from the server clock (Section 3.4), removing
client-asserted-time gaming. The gateway MAY report its own clock (gatewayTime) and
per-call start instants (startedAt, Section 7.2); both are recorded as evidence and never
enforced against — a wrong clock is a fact about the gateway's estate, not a verdict about
the customer whose call it carries. Persistent skew
beyond tolerance is a trust signal on the integration's profile, never a refusal.¶
The gateway is a trusted metering point. Its reports are attestations of consumption, not proofs: the protocol bounds what a dishonest or compromised gateway can cost (grants, ceilings, caps, tenant binding, gateway ownership, revocation, rate limits) but cannot verify token counts it did not observe. The residual — systematic under- or mis-reporting — is addressed outside the request path: integration grading by the conformance suite, and slow-clock observation of the executor. Stated plainly so no one mistakes the boundary: a tenant's spend guarantee holds against its subscribers; the operator's protection against a dishonest metering point is detection and contract, not cryptography, in version 1.¶
Every version-1 decision is automated. No human participates in the request path — for
a grant, a refusal, or a stop. A refusal says so on the wire (automatedDecision, Section 6.4)
and MAY carry a tenant-configured human-review channel (reviewContact), so a subscriber
facing an adverse automated decision has a route to a person; the notice travels with the
decision it describes rather than living only in documentation.¶
No money crosses outward. Grants and receipts are denominated in work units only; a gateway never learns a balance, a price, or a currency. This is deliberate — a commercial and privacy boundary, not an omission.¶
Privacy. accountRef SHOULD be a pseudonymous, tenant-scoped identifier (never a raw
MSISDN, e-mail, or name); the server treats it as an opaque key. limitOwner is a
presentation-safe display label declared by the sponsor with its relation — never a legal
identity the tenant did not choose to show. gatewayTime, startedAt, and per-request
audit records are retained under the operator's retention policy; the protocol itself mandates retention only
for the late window. Refusal metadata deliberately reveals coarse state (which kind of limit
bound); tenants choosing per-subscriber secrecy beyond that boundary configure presentation,
not the wire.¶
This section records the implementation status of the protocol described in this document, as encouraged by [RFC7942]. It is meant to be removed before publication as an RFC.¶
At the time of writing, one server-side implementation of this protocol exists, built into a commercial telecom online charging system, together with a conformance suite exercising the state machine of Section 3.5, the cumulative reporting rules of Section 7.2, and the lapse boundary of Section 7.5. No independent interoperating implementation exists. Readers should treat the capacity-enforcement semantics of Section 12.2 to Section 12.4 as provisional on that basis.¶
This document establishes the registries below. For each registry, the placeholder registration policy mirrors Section 5: the registries are owned by this document, and new values are allocated only by a new schema version of this specification — a Standards-Action-style posture in the [RFC8126] taxonomy, not Specification Required. The cited sections, not this summary, define the initial values and their meanings.¶
| Registry | Initial contents | Defining section |
|---|---|---|
| Pulse Decision |
GRANT, REFUSE
|
Section 6.3, Section 6.4 |
| Pulse Refusal Reason |
NO_HEADROOM, CEILING_PREEMPT, ACCOUNT_VELOCITY, BALANCE_VELOCITY, GROUP_VELOCITY (version 1.1), MAX_CONCURRENT_ACTIONS, PARENT_STOPPED, NO_LAW, DRAINING, BLOCKED_TARIFF, SERVICE_BARRED, DUPLICATE_ACTION, EXPOSURE_CAP, NO_CAPACITY, PREEMPTED, CLASS_BLOCKED (reserved in version 1) |
Section 6.4 |
| Pulse Stop Reason |
CEILING_REACHED, NO_HEADROOM, CALL_LIMIT, MAX_RUNTIME, INFLATION, SIGNATURES_REQUIRED, PARENT_STOPPED, ADMIN_FORCED, DRAINING, NO_CAPACITY, EXPOSURE_CAP, LAPSED
|
Section 7.4 |
| Pulse Error Code |
UNSUPPORTED_SCHEMA, LIMIT_EXCEEDED, MALFORMED_REPORT, MALFORMED_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA, RATE_LIMITED, UNAVAILABLE, INTERNAL
|
Section 13.1, Section 13.2 |
| Pulse Ending |
CLOSE, STOP, LAPSE
|
Section 2, Section 3.5 |
| Pulse Directive |
CONTINUE, CURRENT, SETTLED, STOP
|
Section 7.3 |
| Pulse Grade |
PROVEN, ATTESTED, BOUNDED
|
Section 6.4 |
| Pulse Reduce Cause |
CAPACITY
|
Section 12.3 |
Formal IANA text, including final registry names and the formal [RFC8126] policy designations, is completed at publication.¶
Except for A.7, the 200 responses below omit, for brevity only: serverTime,
actionLive, currentGrantSeq, currentGrantId, unattributable where applicable,
grantIssuedAt, lawVersion, limits, and settlement receipts where a settling response
requires one — all present in real responses under Section 2/Section 6.3/Section 7.3. A refusal of an unopened
Action (A.5) and an error envelope (A.7) carry no Action-state fields; A.7 intentionally
shows serverTime. maxSchemaVersion is shown throughout. Every field shown conforms to
the Section 4.3 type rules, except A.7's deliberately malformed estimate — a JSON number where
Section 4.3 requires a canonical decimal string, the defect that example exists to show.¶
The two abbreviated outcomes below show the distinct conditional metadata rules of Section 6.4:
retryAfter accompanies a retryable refusal, while the sponsor's party-scoped bound carries
limitOwner. Both carry the automated-decision notice; the second additionally shows a
tenant-configured human-review channel. This is a version-1.1 exchange (schemaVersion: 2,
Section 5) — the disclosure fields belong to schema version 2; the other examples show version-1.0
exchanges. They are alternatives for separate Authorise attempts, not two responses to one
request.¶
This exchange happens between A.2 and A.3, while the Action is still LIVE — after A.3's
close, the same stale body would instead draw the terminal directive (Section 3.5, ENDED rows).
This non-final body differs from the retained report for g-000117, so it is not an
identity replay. Both values are below the stored high-water after A.2; the max-merge adds
zero, no ending condition holds, and the standing grant is re-presented (Section 3.5, Section 7.3). The
map rendering follows Section 7.3's weighted-difference description; Appendix B, schema note 5,
records the conflicting domain wording and keeps this representation provisional.¶
Here an Authorise estimate uses a JSON number instead of the canonical decimal string required by Section 4.3, so the request fails state-neutrally with the Section 13.1 envelope.¶
PROVISIONAL — HAND-WRITTEN: These schemas are hand-written from the prose of this memo. They are to be regenerated from the reference implementation's validated model, as Status of This Memo already specifies for the field-presence matrix. On any conflict, the prose is authoritative.¶
The schemas use CDDL [RFC8610] to describe the JSON bodies. They do not define an additional
wire format. Settlement-receipt members are top-level response members; there is no nested
receipt object. Regular expressions encode the principal lexical restrictions; comments
retain the calendar and character-class details not usefully repeated in the message shapes.
The schemas describe the current protocol version (1.1, schemaVersion 2); members and
values whose comment says version 1.1 belong to schema version 2 (Section 5) — a
schema-version-1 exchange omits those fields, while the new value may still arrive under
Section 5's tolerance rule.¶
; ---------- Lexical and numeric types (section 4.3) ----------
schema-version = 1..9007199254740991
count = 0..9007199254740991
generation = 1..9007199254740991
positive-count = 1..9007199254740991
; At most 128 characters from [A-Za-z0-9._:-].
identifier = tstr .regexp "[A-Za-z0-9._:-]{0,128}"
; [a-z0-9_]{1,64}.
unit-type = tstr .regexp "[a-z0-9_]{1,64}"
; The expression fixes the wire layout. Year range and calendar
; validity remain
; subject to section 4.3.
timestamp = tstr .regexp
"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z"
; At most 64 printable UTF-8 characters, excluding control and bidi
; controls.
display-label = tstr
; Each decimal below is a canonical decimal JSON string with scale at
; most 6.
request-decimal = tstr .regexp
"1000000000|(0|[1-9][0-9]{0,8})(\\.[0-9]{0,5}[1-9])?"
pooled-decimal = tstr .regexp
"(0|[1-9][0-9]{0,16})(\\.[0-9]{0,5}[1-9])?"
positive-pooled-decimal = tstr .regexp
"0\\.[0-9]{0,5}[1-9]|[1-9][0-9]{0,16}(\\.[0-9]{0,5}[1-9])?"
positive-weight = tstr .regexp
"1000000|0\\.[0-9]{0,5}[1-9]|[1-9][0-9]{0,5}(\\.[0-9]{0,5}[1-9])?"
canonical-decimal = tstr .regexp
"(0|[1-9][0-9]*)(\\.[0-9]{0,5}[1-9])?"
request-unit-map = { *16 unit-type => request-decimal }
cumulative-unit-map = { *16 unit-type => request-decimal }
weight-map = { *16 unit-type => positive-weight }
; The prose leaves this as a representation ambiguity (schema note
; 5), but
; both members of one response have the same representation.
current-meter-fields =
(
baselineCumulative: cumulative-unit-map,
meterCumulative: cumulative-unit-map
)
//
(
baselineCumulative: pooled-decimal,
meterCumulative: pooled-decimal
)
; ---------- Closed value sets (sections 5, 6.4, 7.4) ----------
ending = "CLOSE" / "STOP" / "LAPSE"
directive = "CONTINUE" / "CURRENT" / "SETTLED" / "STOP"
grade = "PROVEN" / "ATTESTED" / "BOUNDED"
reduce-cause = "CAPACITY"
error-code =
"UNSUPPORTED_SCHEMA"
/ "LIMIT_EXCEEDED"
/ "MALFORMED_REPORT"
/ "MALFORMED_REQUEST"
/ "UNAUTHORIZED"
/ "FORBIDDEN"
/ "NOT_FOUND"
/ "METHOD_NOT_ALLOWED"
/ "UNSUPPORTED_MEDIA"
/ "RATE_LIMITED"
/ "UNAVAILABLE"
/ "INTERNAL"
stop-reason-without-owner =
"CEILING_REACHED"
/ "NO_HEADROOM"
/ "CALL_LIMIT"
/ "MAX_RUNTIME"
/ "INFLATION"
/ "SIGNATURES_REQUIRED"
/ "PARENT_STOPPED"
/ "ADMIN_FORCED"
/ "DRAINING"
/ "NO_CAPACITY"
/ "LAPSED"
stop-fields =
(
stopReason: "EXPOSURE_CAP",
limitOwner: display-label
)
//
(
stopReason: stop-reason-without-owner
)
; ---------- Shared response envelope (sections 2, 5, 6.3) ----------
versioned-success = (
schemaVersion: schema-version,
maxSchemaVersion: schema-version,
serverTime: timestamp
)
action-envelope = (
actionLive: bool,
currentGrantSeq: generation,
currentGrantId: identifier,
unattributable: bool
)
live-action-envelope = (
actionLive: true,
currentGrantSeq: generation,
currentGrantId: identifier,
unattributable: bool
)
ended-action-envelope = (
actionLive: false,
currentGrantSeq: generation,
currentGrantId: identifier,
unattributable: bool
)
ended-unattributable-envelope = (
actionLive: false,
currentGrantSeq: generation,
currentGrantId: identifier,
unattributable: true
)
; serverTime and maxSchemaVersion occur on every response. The four
; Action
; fields occur on every 200 that identifies an opened Action. They
; are absent
; from error envelopes and refusals of unopened Actions.
; ---------- Shared lifecycle-grant fields (section 6.3) ----------
limits = {
maxCallsPerAction: count,
maxRuntimeSec: count,
maxConcurrentActions: count,
? signaturesRequired: bool,
? capacity-limit-fields
}
capacity-limit-fields = (
minRate: canonical-decimal,
enforcementWindowSec: positive-count
)
reduced-grant-fields = (
rateReduced: true,
reduceCause: reduce-cause
)
lifecycle-grant-fields = (
grantId: identifier,
grantSeq: generation,
grantIssuedAt: timestamp,
grantedUnits: positive-pooled-decimal,
reportAt: positive-pooled-decimal,
expiresAt: timestamp,
unitWeights: weight-map,
? quantumUnits: positive-pooled-decimal,
? timeBandId: identifier,
lawVersion: count,
limits: limits,
? reduced-grant-fields
)
; ---------- Settlement receipts (sections 3.3, 6.5, 7.3) ----------
accepted-call = {
callId: identifier,
units: request-unit-map
}
receipt-core = (
settlementId: identifier,
acceptedCumulative: cumulative-unit-map,
cyclePooledUnits: pooled-decimal,
? clamped: true
)
; Required on a settling Report response. acceptedCalls has exactly
; the Calls
; present in that Report request.
fresh-report-receipt = (
receipt-core,
acceptedCalls: [*256 accepted-call]
)
late-report-receipt = (
receipt-core,
acceptedCalls: [*256 accepted-call],
adjustment: true
)
terminal-settlement-receipt =
fresh-report-receipt // late-report-receipt
; Used for the "current receipt" on a zero-merge terminal response.
terminal-current-receipt = (
receipt-core,
? acceptedCalls: [*256 accepted-call]
)
; acceptedCalls appears on Report responses only.
authorise-settlement-receipt = (
receipt-core,
? adjustment: true
)
; The accepted baseline is the only member explicitly named for a
; live
; Authorise re-presentation; the remaining receipt members are
; provisional.
authorise-baseline-receipt = (
acceptedCumulative: cumulative-unit-map,
? settlementId: identifier,
? cyclePooledUnits: pooled-decimal,
? clamped: true
)
; A flat opening has no units to accept.
flat-opening-receipt = (
settlementId: identifier
)
; ---------- Authorise request (section 6.1) ----------
authorise-request = {
schemaVersion: schema-version,
actionId: identifier,
tenantRef: identifier,
accountRef: identifier,
actionType: identifier,
? estimate: request-unit-map,
? scope: identifier,
? parentActionId: identifier,
? gatewayTime: timestamp
}
; ---------- Authorise GRANT responses (sections 3.3, 6.3, 6.5)
; ----------
authorise-grant-response =
authorise-live-grant-response
/ authorise-flat-grant-response
/ authorise-terminal-grant-response
authorise-live-grant-response = {
versioned-success,
live-action-envelope,
decision: "GRANT",
lifecycle-grant-fields,
? authorise-baseline-receipt
}
authorise-flat-grant-response = {
versioned-success,
ended-action-envelope,
decision: "GRANT",
grantId: identifier,
grantSeq: generation,
? timeBandId: identifier,
settled: true,
endedAt: timestamp,
? ending: "CLOSE",
flat-opening-receipt
}
last-grant-reference = (
grantId: identifier,
grantSeq: generation
)
authorise-terminal-grant-response = {
versioned-success,
ended-action-envelope,
decision: "GRANT",
? last-grant-reference,
? settled: true,
ending: ending,
endedAt: timestamp,
authorise-settlement-receipt
}
; ---------- Authorise REFUSE response (section 6.4) ----------
unopened-refusal-fields =
(
refusalReason: "NO_CAPACITY" / "PREEMPTED",
retryable: true,
retryAfter: count,
grade: "BOUNDED",
? limitOwner: display-label
)
//
(
refusalReason: "DRAINING",
retryable: true,
retryAfter: count,
grade: "ATTESTED"
)
//
(
refusalReason:
"ACCOUNT_VELOCITY"
/ "BALANCE_VELOCITY"
/ "GROUP_VELOCITY" ; v1.1 (schemaVersion 2)
/ "MAX_CONCURRENT_ACTIONS",
retryable: true,
retryAfter: count,
grade: "PROVEN",
? limitOwner: display-label
)
//
(
refusalReason: "SERVICE_BARRED",
retryable: false,
? retryAfter: count,
grade: "ATTESTED"
)
//
(
refusalReason: "EXPOSURE_CAP",
retryable: false,
? retryAfter: count,
grade: "PROVEN",
limitOwner: display-label
)
//
(
refusalReason:
"NO_HEADROOM"
/ "CEILING_PREEMPT",
retryable: false,
? retryAfter: count,
grade: "PROVEN",
? limitOwner: display-label
)
//
(
refusalReason: "PARENT_STOPPED" / "NO_LAW" / "BLOCKED_TARIFF",
retryable: false,
? retryAfter: count,
grade: "PROVEN"
)
duplicate-refusal-fields = (
refusalReason: "DUPLICATE_ACTION",
retryable: false,
? retryAfter: count,
grade: "PROVEN"
)
authorise-refuse-response =
authorise-unopened-refuse-response
/ authorise-duplicate-refuse-response
authorise-unopened-refuse-response = {
versioned-success,
decision: "REFUSE",
automatedDecision: true, ; v1.1 (schemaVersion 2)
? reviewContact: display-label, ; v1.1 (schemaVersion 2)
unopened-refusal-fields
}
authorise-duplicate-refuse-response = {
versioned-success,
decision: "REFUSE",
automatedDecision: true, ; v1.1 (schemaVersion 2)
? reviewContact: display-label, ; v1.1 (schemaVersion 2)
duplicate-refusal-fields,
? action-envelope
}
; CLASS_BLOCKED is reserved and never emitted in version 1.
; ---------- Report request (sections 7.1, 7.2) ----------
report-call = {
callId: identifier,
units: request-unit-map,
? startedAt: timestamp, ; v1.1 (schemaVersion 2)
? signature: tstr
}
report-request = {
schemaVersion: schema-version,
actionId: identifier,
tenantRef: identifier,
accountRef: identifier,
calls: [*256 report-call],
final: bool,
? gatewayTime: timestamp
}
; The path grantId participates in request identity but is not a body
; member.
; ---------- Report responses (sections 3.5, 7.3, 7.6) ----------
report-response =
report-continue-response
/ report-current-response
/ report-stop-response
/ report-settled-response
; actionLive can be false when a retained CONTINUE outcome is
; replayed after
; the Action has ended; the fresh envelope overrides the stored
; grant.
report-continue-response = {
versioned-success,
action-envelope,
directive: "CONTINUE",
lifecycle-grant-fields,
fresh-report-receipt
}
report-current-response = {
versioned-success,
live-action-envelope,
directive: "CURRENT",
lifecycle-grant-fields,
current-meter-fields
}
report-stop-response =
report-stop-settlement-response
/ report-stop-current-response
/ report-stop-unattributable-response
report-stop-settlement-response = {
versioned-success,
ended-action-envelope,
directive: "STOP",
stop-fields,
endedAt: timestamp,
terminal-settlement-receipt
}
report-stop-current-response = {
versioned-success,
ended-action-envelope,
directive: "STOP",
stop-fields,
endedAt: timestamp,
terminal-current-receipt
}
report-stop-unattributable-response = {
versioned-success,
ended-unattributable-envelope,
directive: "STOP",
stop-fields,
endedAt: timestamp,
? terminal-current-receipt
}
report-settled-response =
report-settled-settlement-response
/ report-settled-current-response
/ report-flat-settled-response
/ report-settled-unattributable-response
report-settled-settlement-response = {
versioned-success,
ended-action-envelope,
directive: "SETTLED",
endedAt: timestamp,
terminal-settlement-receipt
}
report-settled-current-response = {
versioned-success,
ended-action-envelope,
directive: "SETTLED",
endedAt: timestamp,
terminal-current-receipt
}
report-flat-settled-response = {
versioned-success,
ended-action-envelope,
directive: "SETTLED",
endedAt: timestamp,
? settlementId: identifier
}
report-settled-unattributable-response = {
versioned-success,
ended-unattributable-envelope,
directive: "SETTLED",
endedAt: timestamp,
? terminal-current-receipt
}
; A retained terminal replay has the same stored directive body and
; receipt
; as the original response, but versioned-success and action-envelope
; are
; recomputed. A replayed CONTINUE can therefore carry actionLive:
; false.
terminal-stored-outcome-replay =
report-continue-response
/ report-stop-response
/ report-settled-response
; ---------- Error envelope (sections 5, 13.1, 13.2) ----------
error-envelope = {
? schemaVersion: count,
errorCode: error-code,
message: any,
serverTime: timestamp,
maxSchemaVersion: schema-version,
? retryable: bool,
? retryAfter: count
}
; schemaVersion is present only when parseable. Error envelopes never
; carry
; actionLive, currentGrantSeq, currentGrantId, or unattributable.
¶
Section 6.5 requires a live Authorise replay to include the standing grant's
"cycle-baseline receipt", but explicitly identifies only acceptedCumulative.
Section 7.3 defines the remaining receipt fields for settling Report responses, while
the first grant has no preceding settlement. The schema therefore makes the baseline
receipt optional and makes its other members optional.¶
An Authorise terminal acknowledgement explicitly contains decision, ending,
endedAt, and the final receipt, with settled "where applicable". It does not say
whether grantId and grantSeq are repeated, or enumerate the applicability of
settled. The schema makes the last-grant reference and settled optional.¶
The initial flat-GRANT state-machine row names settled and endedAt, while an
ended-Action Authorise replay names ending. It is not explicit whether the initial
flat response also carries ending: "CLOSE"; the schema makes it optional.¶
The shared-envelope definition requires Action-state fields on every 200 that
identifies an opened Action and excludes them on refusals of unopened Actions. It is
unclear whether REFUSE / DUPLICATE_ACTION counts as identifying the already-opened
Action. The unopened REFUSE shape excludes the envelope; only the duplicate-Action
branch optionally permits the complete envelope.¶
Sections 3.5 and 7.3 describe baselineCumulative and meterCumulative as meter states
whose difference is weighted. Section 4.3 groups their "pooled values" with derived
quantities in [0, 10^17). The examples provisionally use per-unit maps because the
weighted-difference operation needs them; current-meter-fields permits either a
map/map or pooled-scalar/pooled-scalar representation because the exact JSON shape and value
domain require adjudication.¶
A zero-merge terminal response carries the "current receipt", but Section 7.3 defines
acceptedCalls relative to the Calls present in the request that caused a settlement.
It is unclear what acceptedCalls contains for a non-settling zero-merge request, so it
is optional in terminal-current-receipt.¶
For recognised new consumption that can no longer be settled, Section 3.5 says there is no
settlementId, while Section 7.6 says there is no new settlementId and also refers to the
stored terminal outcome. It is unclear whether an old current receipt remains present;
the unattributable terminal shapes therefore permit it optionally.¶
A Report to a flat Action receives SETTLED and never settles. The prose does not say
whether the response re-presents the flat opening receipt's settlementId; the flat
Report shape therefore makes it optional.¶
retryAfter is required when an Authorise refusal has retryable: true, but its
presence is not expressly forbidden or defined when retryable: false. The false
variants therefore model it as optional. The error envelope separately leaves
retryable and retryAfter independently optional and does not couple them to an HTTP
status.¶
The JSON type, length, and character domain of error-envelope message are not stated
in Section 4.3 or Section 13.1. The provisional schema uses any; if a JSON string is intended, an
authoritative field rule is needed before regeneration.¶
Section 7.3 does not state whether each acceptedCalls[].units map is complete over
the declared unit types or contains only the keys relevant to that Call or request.
The schema permits any within-limit unit map.¶
The exact trigger for clamped: true is not fully mechanical: Section 7.3 says "limited by
the meter or a ceiling", although every settlement is bounded by the meter. The schema
preserves the stated optional presence and does not infer a trigger.¶
minRate is a pooled-units-per-second decimal, but Section 4.3 does not unambiguously assign
it the request, weight, or derived-pooled upper domain. The schema enforces only the
canonical decimal representation pending regeneration.¶
The 256 KB request limit does not say whether KB is decimal or binary, so CDDL cannot
express an exact byte .size bound without choosing a meaning.¶
Section 7.7 requires a Report that flushes the final meter after STOP, but does not
state the value of that Report's required final field. The request schema accepts
either Boolean, and the outage diagram does not choose one.¶
Section 6.4 requires limitOwner for a party-scoped bound but does not enumerate every
refusal reason that can be party-scoped. Section 12.1 makes it unconditionally present
for EXPOSURE_CAP. The schema requires it there, permits it on the bound-like refusal
shapes, and excludes it from conditions that the prose does not describe as bounds.¶
This appendix follows one quantised lifecycle from Authorise through CLOSE. It illustrates the cumulative pipeline of Section 7.2; that section remains authoritative. All quantities are work units or pooled units — never money.¶
The Action Type declares two unit types with distinct weights and a pooled quantum of 10:¶
| Unit type | Weight |
|---|---|
tokens_in
|
0.333333
|
tokens_out
|
2.5
|
The gateway opens the Action with both types in its estimate:¶
The full opening GRANT is:¶
The first cycle baseline is the zero meter (Section 7.3):
M0 = {tokens_in: 0, tokens_out: 0}. Applying Section 7.2 gives pooled total P0 = 0
and block count B0 = ceil(P0 / 10) = ceil(0 / 10) = 0.¶
For each accepted meter Mk, the calculations below use the Section 7.2 formulas:¶
P_u(k) = HALF_UP_6(weight_u * cumulativeTotal_u(k)) P(k) = exact sum of P_u(k) over unit types D(k) = P(k) - P(cycle baseline) ; raw cycle pool consumption B(k) = ceil(P(k) / quantumUnits) DB(k) = B(k) - B(previous settlement) ; newly charged block delta¶
D(k), not DB(k) * quantumUnits, is compared with reportAt and the grant pool
(Section 7.2). cyclePooledUnits carries that raw pre-quantisation delta (Section 7.3).¶
At 2026-09-01T10:01:00.000Z, the gateway reports one Call cumulatively against G1:¶
{
"schemaVersion": 1,
"actionId": "arith-action-1",
"tenantRef": "4102",
"accountRef": "subscriber-arith",
"calls": [
{ "callId": "c1",
"units": { "tokens_in": "10", "tokens_out": "4.666669" } }
],
"final": false
}
¶
The cumulative products and exact sum are:¶
P_in(1) = HALF_UP_6(0.333333 * 10)
= HALF_UP_6(3.333330) = 3.33333 (section 7.2)
P_out(1) = HALF_UP_6(2.5 * 4.666669)
= HALF_UP_6(11.6666725) = 11.666673 (section 7.2)
P(1) = 3.33333 + 11.666673 = 15.000003 (section 7.2)
D(1) = P(1) - P0 = 15.000003 - 0 = 15.000003 (section 7.2)
¶
Thus D(1) = 15.000003 >= reportAt 15 and D(1) <= grantedUnits 20. The crossing runs a
settling cycle and produces CONTINUE with G2. Quantised rating is cumulative:¶
B(1) = ceil(15.000003 / 10) = 2 (section 7.2) DB(1) = B(1) - B0 = 2 - 0 = 2 newly charged blocks (section 7.2)¶
Those two blocks represent 20 pooled units in the quantised rating. No such derived block field is added to the wire. The receipt members in the top-level response are exactly:¶
{
"settlementId": "s-arith-1",
"acceptedCumulative": { "tokens_in": "10",
"tokens_out": "4.666669" },
"acceptedCalls": [
{ "callId": "c1",
"units": { "tokens_in": "10", "tokens_out": "4.666669" } }
],
"cyclePooledUnits": "15.000003"
}
¶
Neither clamped nor adjustment is present. CONTINUE mints G2 with
grantId: "g-arith-2", grantSeq: 2,
grantIssuedAt: "2026-09-01T10:01:00.000Z", grantedUnits: "12", reportAt: "9", and
expiresAt: "2026-09-01T10:06:00.000Z"; its pinned weights, quantum, law, and limits are
unchanged. Its fresh envelope has actionLive: true, currentGrantSeq: 2,
currentGrantId: "g-arith-2", and unattributable: false. The cycle baseline moves to
M1 = {tokens_in: 10, tokens_out: 4.666669}, whose pooled value is P(1) (Section 7.3).¶
At 2026-09-01T10:01:10.000Z, a stale worker sends a different non-final request against
superseded G1 with c1 = {tokens_in: 9, tokens_out: 4.5}. It is not the retained Cycle 1
identity. Per Section 7.2, the max-merge is:¶
max({10, 4.666669}, {9, 4.5}) = {10, 4.666669}; meter increase = 0
¶
No ending condition holds. Under Section 3.5 LIVE row 6 and Section 7.3, the response is CURRENT. The full standing G2 grant is re-presented; the selected convergence fields are below. As in A.6, the per-unit map rendering is provisional under Appendix B, schema note 5.¶
{
"directive": "CURRENT",
"grantId": "g-arith-2",
"grantSeq": 2,
"baselineCumulative": { "tokens_in": "10",
"tokens_out": "4.666669" },
"meterCumulative": { "tokens_in": "10", "tokens_out": "4.666669" },
"actionLive": true,
"currentGrantSeq": 2,
"currentGrantId": "g-arith-2",
"unattributable": false
}
¶
There is no settlement, receipt, charge, or mint; the current generation remains 2.¶
At 2026-09-01T10:02:00.000Z, the gateway reports against G2 with the cumulative meter
M2 = {tokens_in: 15, tokens_out: 7.8}:¶
P_in(2) = HALF_UP_6(0.333333 * 15) = 4.999995 (section 7.2)
P_out(2) = HALF_UP_6(2.5 * 7.8) = 19.5 (section 7.2)
P(2) = 4.999995 + 19.5 = 24.499995 (section 7.2)
D(2) = P(2) - P(1)
= 24.499995 - 15.000003 = 9.499992 (section 7.2)
¶
D(2) = 9.499992 >= reportAt 9 and D(2) <= grantedUnits 12, so this is another
settlement and re-grant. The cumulative block calculation is:¶
B(2) = ceil(24.499995 / 10) = 3 (section 7.2) DB(2) = B(2) - B(1) = 3 - 2 = 1 newly charged block (section 7.2)¶
The exact top-level receipt members are:¶
{
"settlementId": "s-arith-2",
"acceptedCumulative": { "tokens_in": "15", "tokens_out": "7.8" },
"acceptedCalls": [
{ "callId": "c1",
"units": { "tokens_in": "15", "tokens_out": "7.8" } }
],
"cyclePooledUnits": "9.499992"
}
¶
Again, clamped and adjustment are absent. CONTINUE mints G3 with
grantId: "g-arith-3", grantSeq: 3,
grantIssuedAt: "2026-09-01T10:02:00.000Z", grantedUnits: "10", reportAt: "8", and
expiresAt: "2026-09-01T10:07:00.000Z". Its envelope carries current generation 3 and
current id g-arith-3. The new baseline is
M2 = {tokens_in: 15, tokens_out: 7.8} with pooled value P(2) (Section 7.3).¶
At 2026-09-01T10:02:30.000Z, the gateway sends final: true against G3 with cumulative
meter M3 = {tokens_in: 18, tokens_out: 10.1}. The close is allowed before reportAt; it
settles the full remaining meter delta (Section 3.5, Section 7.3):¶
P_in(3) = HALF_UP_6(0.333333 * 18) = 5.999994 (section 7.2)
P_out(3) = HALF_UP_6(2.5 * 10.1) = 25.25 (section 7.2)
P(3) = 5.999994 + 25.25 = 31.249994 (section 7.2)
D(3) = P(3) - P(2)
= 31.249994 - 24.499995 = 6.749999 (section 7.2)
B(3) = ceil(31.249994 / 10) = 4 (section 7.2)
DB(3) = B(3) - B(2) = 4 - 3 = 1 newly charged block (section 7.2)
¶
D(3) = 6.749999 <= grantedUnits 10. The exact terminal receipt members are:¶
{
"settlementId": "s-arith-3",
"acceptedCumulative": { "tokens_in": "18", "tokens_out": "10.1" },
"acceptedCalls": [
{ "callId": "c1",
"units": { "tokens_in": "18", "tokens_out": "10.1" } }
],
"cyclePooledUnits": "6.749999"
}
¶
Neither optional flag is present. The response directive is SETTLED; the Action's ending
is CLOSE and endedAt is 2026-09-01T10:02:30.000Z. The closing outcome mints generation
4 without minting a serving grant. The terminal envelope is therefore
actionLive: false, currentGrantSeq: 4, currentGrantId: "g-arith-3", and
unattributable: false (Section 3.5).¶
As a final check, the raw receipt deltas sum without cadence loss:¶
15.000003 + 9.499992 + 6.749999 = 31.249994 = P(3) - P0¶
The charged block deltas also telescope:¶
2 + 1 + 1 = 4 = ceil(31.249994 / 10)¶
The stale report contributes zero to both checks. This is the cadence-independence property of the cumulative pipeline in Section 7.2.¶
The implementing charging stack and its surrounding documents use an established telecom vocabulary; Pulse deliberately uses short, wire-suited, vendor-neutral names. The table translates between the two. The wire vocabulary is the protocol's; the left column is one implementer's, recorded here so that a reader of either vocabulary can read the other.¶
Each D.1 definition is the opening of the framework's own definition box in its current edition, pasted rather than paraphrased; the objects are identical in v3.1, the edition this document cites, except row 17 (Relation: v3.1's box reads "EXPOSED or EXECUTES"; HOLDS and RELIES arrived at v4.0). "No wire concept" is a deliberate entry, not a gap: the concept lives entirely server-side, and the gateway sees only its consequences.¶
Framework terms not listed here — every Event the engine writes, the Class, the Draw-down Order, the Night, the Bill, the Commercial Group, the Governance Floor, the Platform, the record kinds — are server-side objects with no wire counterpart, and their absence is deliberate. Their one-line dispositions, together with every term this document and its implementation use, are carried in the Pulse vocabulary register: a companion document owned with this specification, seeded from the framework's register of 6 September 2026 and checked against every draft by script, so that silence here is declared once rather than mistaken for support (Section 1.4).¶
| # | Framework term — the opening of its definition box | This specification |
|---|---|---|
| 1 | Action — One customer-visible unit of work with one price question: a question answered, a picture generated, a research task completed. | Action (shared term) |
| 2 | Balance — Money with rules: an amount, a priority in the draw-down order, optionally a Scope and an Expiry, and a declared behaviour at its limit. |
No wire concept — the server holds it; the gateway sees refusals and stops. scope names a Balance's Scope (Section 6.1) |
| 3 | Ceiling — The most one Action may charge — "up to" as an enforced number. |
Ceiling / headroom (shared term, Section 2); it reaches the wire only as CEILING_REACHED (stop) and CEILING_PREEMPT (refusal). limits is a different object (the disclosed protective bounds, Section 6.3), and limitOwner belongs to the sponsor cap (Section 12.1) |
| 4 | Countable — A number computed from records that answers one operational question, on a stated cadence and at a stated grain. |
No wire concept — the meter's unit is the framework's counted unit: units and the unit types (Section 7.2) |
| 5 | Cycle — One Quota's span: grant, consume, settle. A reserved Action is a chain of Cycles. | Cycle (shared term); the cycle Report rides it (Section 7.3) |
| 6 | Expiry — A date after which a Balance's unreserved credits are written off. Reserved credits are spared until their Actions settle. |
No wire concept. Through draft-03 this document's ending for a lapsed grant was named EXPIRY and the two were false friends; from draft-04 that ending is LAPSE (D.2) |
| 7 | Facility — One regulatory regime, as fine-grained as reality requires — Belgium; Kenya; a single US state where its rules differ. |
No wire concept — tenantRef identifies the Tenant, which a Facility contains (D.2) |
| 8 | Grant — no definition box: the second step of the loop Authorise · Grant · Supervise · Settle, the act of issuing a Quota |
decision: GRANT (Section 6.3) — the act. The object the framework calls a Quota is this document's Grant (row 16) |
| 9 | Metering Point — The component that executes Inference Calls, counts consumption and obeys the Quota. In the AI ecosystem the role is played by the gateway. | The Gateway (Section 2) — Section 15.7's attesting party |
| 10 |
Node — no definition box; the reference engine's class Node: one path from the Platform through the Facility chain to a Tenant
|
tenantRef, and accountRef for the position below — not scope
|
| 11 | Packet (Substantiation Packet) — Everything needed to defend one Charge to a regulator or a court, in five limbs. | Partial: the per-settlement receipt (Section 7.3) is the wire-visible record; the Packet itself is off-wire |
| 12 | Party — A legal or natural person with standing in at least one relation. The Framework records that a party holds a relation, or changed; it never verifies who they are. | The roles a party takes on this wire: sponsor (limitOwner), subscriber (accountRef), gateway (the credential), tenant (Section 2); limitOwner names the exposed one (Section 12.1) |
| 13 | Period — The Bill's integration window, opened and closed on the Facility's calendar. | The sponsor's declared accounting window (Section 12.1) — narrower; the Bill's Period has no wire term |
| 14 | Price Book — A Tenant's complete set of Action Types with their price forms and elements, held as a versioned object with an identity. | No wire object — lawVersion is "the pinned plan/price-book version" (Section 6.3); the book itself is the tenant's published catalogue, server-side |
| 15 | Profile (Metering Point Profile) — The record of what an integration actually does, per route … Established by running the conformance suite against it, never by reading its documentation. | Integration profile (Section 2, Section 12.4) — a related object under a different name; not this document's deployment profile — the false friend below |
| 16 | Quota — The Reservation's outside face: the same amount expressed to the Metering Point in work units, carrying a report threshold and a Validity. |
Grant (grantedUnits, reportAt, expiresAt) — the RFC 4006 granted-quota concept (Section 1.2) |
| 17 | Relation — A dated, typed, scoped link between a Party and work: EXPOSED or EXECUTES. (v3.1's box; the current edition adds the record relation, HOLDS and RELIES) |
No wire concept — the wire sees the sponsor relation only through limitOwner; the exposure relation's aggregate is EXPOSURE_CAP's basis (Section 12.1) |
| 18 | Reservation — Credits set aside on a Balance for one Action, under the Solvency Rule. The promise held against money. | An open grant (pool exposure) — "spend the sponsor is already standing behind" (Section 12.1) |
| 19 | Action Type — The Price Book row an Action instantiates: its price form, its elements, its counted unit, its thresholds. |
Action Type (actionType; Section 2, Section 6.1) |
| 20 | Effective Law — The single package of properties an Action receives at open: the path from Platform through its Facility chain to its Tenant, resolved top-down. | The law pinned at open (Section 2, Section 6.2); lawVersion identifies its version |
| 21 | Tenant — Whose money and commercial law. A Tenant owns its Price Book, its Balances and one settlement relationship, and attaches to exactly one Facility. |
Tenant (tenantRef; Section 2, Section 6.1) |
| 22 | Weights — Per-type conversion from counted units to billing units … Part of the price, pinned at open. |
unitWeights (Section 6.3) — the complete map, pinned with the law |
| 23 | stop_reason (the Section 9 field) — Why the Action must end now: the Trip-wire, the cascade … |
directive: STOP with stopReason (Section 7.3, Section 7.4) — the stop obligation |
The sponsor-cap concept, by its framework name: what EXPOSURE_CAP enforces is the
framework's Solvency Rule at a second scope — "a bound on exposure ranges over a
relation, not over any one Balance." Where the engine enforces open grants ≤ the Balance,
it also enforces commitments across the relation ≤ the exposure bound; Section 12.1 states the
same basis (committed + settled, per relation) explicitly.¶
Two cross-vocabulary notes: the framework's fourth velocity level,
RELATION_VELOCITY (Actions started and credits charged per minute, counted across a
declared relation), is this document's GROUP_VELOCITY — the same object, counted as
the framework defines it, in both Actions started and credits charged. And the framework
closes its refusal vocabulary (fourteen reasons) but enumerates no closed set of stop
reasons — its reference engine's stop codes are the nearest list; this document closes
both registries per version (Section 5, Section 7.4, Section 13.2) — a protocol decision, not a divergence.¶
| Implementation / telecom term | This specification |
|---|---|
| Facility / region (tenancy detail below the operator) | No wire concept — subscriber-level detail below the tenant boundary |
| Subscriber (MSISDN) | Account (accountRef — pseudonymous on the wire, Section 15.7) |
| Charging session | Action |
| Decision-tree classification | Action Type (Actions arrive typed; classification collapses into a catalogue lookup) |
| Interim usage report (opIntermediate) | Cycle Report (final: false) |
| Final usage report (opFinal) | Closing Report (final: true, the CLOSE ending) |
| CDR (the concluded-session record) | The concluded Action record (emitted at ending; not a wire object) |
| CODR (per-settlement record) | The cycle settlement and its receipt |
| Time band | Time band (shared term — Section 10 adopted it from the implementation) |
False friends — same word, different meaning:¶
Profile. Three meanings collide. In the charging stack, the subscription profile is the subscriber's stored charging state. In the framework, a Profile is the Metering Point's tested integration record — a test result, not stored state. In this specification, profile appears only as deployment profile — a named set of deployment choices (Section 12, Section 13.5). None of the three is any of the others.¶
Expiry (resolved at draft-04). In the framework, Expiry is a date after which a
Balance's unreserved credits are written off — money is destroyed, and reserved credits
are spared until their Actions settle; the implementation's counter expiry is the same
event. Through draft-03 this document named the ending of a lapsed grant EXPIRY, and
the two were false friends. From draft-04 that ending is LAPSE and its stop reason
LAPSED (the expiresAt field keeps its name): a lapse settles at the reported
meter (Section 7.5) and writes nothing off; an Expiry is a balance event with no wire term.
The word expiry is no longer used for the lapse anywhere in this document.¶
Envelope. Here, the Envelope is the always-fresh part of a response (Section 2). In the framework, an envelope is a money partition — the authorisation floor. Neither is the other.¶
Minimum. The framework's Minimum is a price form: a per-Action floor on the Charge.
Its counterpart here is the contractual minimum reserved at open (Section 6.2) and never
applied a second time (Section 7.6) — no wire field. limits (Section 6.3) are the disclosed
protective bounds, and limits.minRate (Section 12.2) is a capacity rate: three objects, one
word apart.¶
Facility. In the framework, a Facility is a regulatory regime that contains
Tenants. In the implementation, a facility is a node of the operator's tenancy tree
below the tenant (the root facility is the tenant). Neither reaches the wire;
tenantRef is the Tenant.¶
One field, two uses — signature. On this wire, signature is a reserved, opaque
per-call attestation (Section 7.2), enforced only where a bilateral profile is agreed
(SIGNATURES_REQUIRED, Section 7.4). The framework's reference engine uses a per-call signature
as a fingerprint for repeat detection (its Inflation wire), and the implementation does the
same server-side with the value it receives. Same field, two purposes — recorded here so
the wire contract and the server-side use are never conflated.¶
This is the first Internet-Draft of this specification. The protocol was developed privately through several revisions during 2026, under an earlier working name, and was reviewed against an independent implementer's assessment before this submission. Later versions record their changes in this appendix.¶