api reference
Intended Documentation
Physical Authority API Reference
Every endpoint, request field, response field, and failure mode for /v1/physical/* — ground truth for direct HTTP integration, matched to the runtime code.
Physical Authority API Reference#
This is the contract for direct HTTP integration against /v1/physical/* — every field below is matched to the runtime (services/api/src/routes/physical.ts). Use it when there is no SDK for your language, or when you need the exact wire format for a verifier.
Base URL and authentication#
Every endpoint requires a verified tenant context. Present a tenant-scoped API key and the tenant header on every call:
A request that cannot resolve a verified tenant is rejected with 401 unauthenticated. The API key authenticates the caller; it does not bind a token to a robot. What binds an issued token to a specific robot is the actorIdentity claim — and that actor must be registered on the tenant roster first (see POST /v1/physical/actors).
The /v1 prefix
The gateway strips a leading /v1 before routing, so /v1/physical/classify and /physical/classify reach the same handler. The paths are written with /v1/ here because that is the public, versioned form.
Endpoint summary#
| Method | Path | Purpose |
|---|---|---|
POST | /v1/physical/classify | Classify a structured goal into an OI code (no token). |
POST | /v1/physical/authority-tokens | Run the authority loop; mint a token on ALLOW. |
POST | /v1/physical/authority-tokens/{jti}/revoke | Revoke a previously-issued token. |
GET | /v1/physical/revocations | Pull revocations since a timestamp (verifier sync). |
POST | /v1/physical/snapshots | Record a state snapshot, return a ref. |
GET | /v1/physical/audit | List audit entries for the tenant (filterable). |
GET | /v1/physical/audit/{jti} | Fetch the audit entry for one issued token. |
GET | /v1/physical/approvals | List escalation tickets (operator inbox). |
GET | /v1/physical/approvals/{ticketId} | Poll one escalation ticket. |
POST | /v1/physical/approvals/{ticketId}/decide | Operator approves or rejects a ticket. |
POST | /v1/physical/actors | Register / update an actor on the fleet roster. |
GET | /v1/physical/actors | List registered actors. |
POST | /v1/physical/policy | Upload a tenant policy (optionally activate). |
GET | /v1/physical/policy | Read the active tenant policy. |
Error envelope#
All non-2xx responses use a nested envelope:
details is present only on some errors. Validation failures carry details.issues (the Zod issue list). Policy denials carry details with the deciding clause and safe-default (see below).
| HTTP | code | Meaning |
|---|---|---|
| 400 | validation_error | Request body failed schema validation; see details.issues. |
| 401 | unauthenticated | No verified tenant context. |
| 403 | actor_not_registered | claims.actorIdentity is not on the tenant roster. Register it first. |
| 403 | forbidden | An Idempotency-Key belongs to a different tenant. |
| 404 | not_found | Token, ticket, or audit entry does not exist for this tenant. |
| 409 | conflict | Idempotency-Key reused with a different body, or a ticket is no longer pending. |
| 422 | policy_denied | Policy denied, or the classifier failed closed; details.safeDefault is the fallback. |
| 423 | policy_denied | Same shape as 422, but the deciding clause is an audit-gap clause (chain stale). |
DENY is 422, not 403
A policy denial is 422 policy_denied, not 403. 403 on this surface means the caller is not allowed (unregistered actor, cross-tenant idempotency key). The audit-gap clause is the one case that maps to 423 instead of 422 — the body shape is identical, the status is the differentiator.
POST /v1/physical/classify
Classifies a structured goal into an OI category. Does not issue a token. Useful when your pipeline wants to inspect the classification before deciding whether to request authority.
StructuredGoal fields
| Field | Type | Required | Notes |
|---|---|---|---|
schema | string | yes | Namespace of the payload, e.g. ros2:action:nav2_msgs/NavigateToPose, opc-ua:1.04:method. Drives which rule matches. |
verb | string | yes | Lower-case kebab action, e.g. move-to, pick, release-payload. |
object | string | null | no | What the verb acts on, e.g. workpiece-12, waypoint-3. |
parameters | object | no | Flat key/value bag of verb parameters (defaults to {}). |
actor.kind | enum | yes | robot | drone | vehicle | cobot | agv | amr | rov | other. |
actor.identifier | string | yes | Identity of the agent issuing the goal. |
sourceTimestampMs | int | no | Edge-side origin time, for replay-window checks. |
Request
Response 200
| Field | Type | Notes |
|---|---|---|
oiCode | string | Matches OI-NNN / OI-NNNN. |
confidence | number | 0–1. |
safetyBit | boolean | When true, safetyCitations cite the relevant OI-29xx safety categories. |
safetyCitations | string[] | Each matches OI-29NN. |
alternatives | array | Up to three lower-ranked { oiCode, confidence }. |
failClosed | boolean | true when no rule matched confidently — treat as a denial. |
explanation | string | null | Rule rationale. |
Classifier coverage is a seed corpus
The structured-goal classifier is a rule-based seed corpus (ROS2 nav2/MoveIt, OPC-UA, PX4/ArduPilot, surgical, AGV/AMR, AV, energy, mining, construction, marine, agriculture). A goal outside the corpus returns oiCode: "OI-1800" (generic actuation), confidence: 0.2, and failClosed: true. This is the only classifier in the platform that emits OI-NNNN codes at runtime; the digital/text path does not.
POST /v1/physical/authority-tokens
Runs the full authority loop and, on ALLOW, mints the token. The endpoint will classify the goal itself if you omit intent.oiCode.
Headers
| Header | Required | Notes |
|---|---|---|
Idempotency-Key | recommended | Same key + same body within 24h replays the original response. Same key + different body → 409 conflict. A key from another tenant → 403 forbidden. |
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
intent.structuredGoal | StructuredGoal | yes | Same shape as /classify. |
intent.oiCode | string | no | Pre-classification (OI-NNN/OI-NNNN). If omitted, the server classifies. |
dagNode.nodeId | string | yes | DAG node identifier; echoed into the token. |
dagNode.oiCode | string | yes | OI code for the node. |
dagNode.deadlineMs | int > 0 | yes | The token's lifetime. expiresAtMs = issuedAt + deadlineMs. |
dagNode.safeDefault | enum | yes | stop | hold-position | request-operator | transition-safe-state | abort-mission | ignore. |
dagNode.realTimeTier | enum | no | best-effort (default) | rt-soft | rt-hard. |
dagNode.streaming | boolean | no | true requires streamingWindowMs. |
claims.actorIdentity | string | yes | Must be registered (else 403 actor_not_registered). |
claims.deadlineMs | int > 0 | yes | — |
claims.safeDefault | string | yes | — |
claims.safetyBit | boolean | no | Defaults true. Overridden by classifier output when the server classifies. |
claims.safetyCitations | string[] | no | Defaults []. |
physicalState | map | no | predicate → PhysicalStateValue (see wire format). Snapshotted into the audit chain. |
operatorTicketId | string | null | no | Set to an approved ticket id when re-issuing after an ESCALATE. |
Response 200 — ALLOW
physicalStateRef is null when no physicalState was submitted. The token is the RS256 JWT detailed in Token claims.
Response 200 — ESCALATE
expiresAtMs here is the ticket TTL (one hour), not a token TTL. Poll /approvals/{ticketId}; once approved, re-call this endpoint with operatorTicketId set.
Response 422 (or 423) — DENY
When the classifier fails closed (no oiCode supplied and no rule matched), the same 422 policy_denied envelope is returned with details.clauseId: "classifier-fail-closed" and details.explanation. When the deciding clause is an audit-gap clause, the status is 423.
Failure modes for this endpoint
| HTTP | code | Cause |
|---|---|---|
| 400 | validation_error | Body failed schema validation. |
| 401 | unauthenticated | No verified tenant. |
| 403 | actor_not_registered | claims.actorIdentity not on the roster. |
| 403 | forbidden | Idempotency-Key belongs to another tenant. |
| 409 | conflict | Idempotency-Key reused with a different body. |
| 422 | policy_denied | Policy denied, or classifier failed closed. |
| 423 | policy_denied | Audit-gap clause tripped. |
The full ALLOW / ESCALATE / DENY branch is illustrated below.
One request, three terminal outcomes. ALLOW mints a token bound to the action; ESCALATE waits on a human; DENY (422, or 423 for audit-gap) carries the clause's safe-default.
POST /v1/physical/authority-tokens/{jti}/revoke
Revokes a previously-issued token. The jti must correspond to an audit entry for this tenant (else 404 not_found).
Request
Response 200
GET /v1/physical/revocations
The endpoint a verifier polls to keep its local revocation list current.
Query
| Param | Type | Notes |
|---|---|---|
since | int ≥ 0 | Return revocations with revokedAtMs > since (defaults to 0). |
Response 200
Poll with since set to the previous response's asOfMs to fetch only new revocations. The server returns at most 1000 entries per call, oldest first.
POST /v1/physical/snapshots
Records a PhysicalStateProvider snapshot in the audit chain without issuing a token — useful for forensic checkpoints or pre-staging state.
Request
Response 200
GET /v1/physical/audit/{jti}
Returns the audit-chain entry for a previously-issued token (scoped to your tenant).
Response 200
verifications is an array of { atMs, edgeNodeId, result } entries appended as edge verifiers report checks. It is empty today — edge-verifier reporting is roadmap.
GET /v1/physical/audit
Lists audit entries for the tenant, most recent first.
Query
| Param | Type | Notes |
|---|---|---|
limit | int 1–200 | Defaults to 50. |
oiCode | string | Filter to one OI code. |
actorIdentity | string | Filter to one actor. |
Response 200: { "entries": [ … ] } — each entry has the shape above.
GET /v1/physical/approvals
Operator inbox — lists escalation tickets, most recent first.
Query
| Param | Type | Notes |
|---|---|---|
status | enum | pending (default) | approved | rejected | expired. |
limit | int 1–200 | Defaults to 50. |
Response 200: { "tickets": [ … ] }.
GET /v1/physical/approvals/{ticketId}
Polls one ticket.
A pending ticket past expiresAtMs is reported as expired. When status == "approved", re-issue against /authority-tokens with operatorTicketId set.
POST /v1/physical/approvals/{ticketId}/decide
Operator approves or rejects a pending ticket.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
decision | enum | yes | approved | rejected. |
operator | string | yes | Identity of the deciding operator. |
reason | string | no | Recorded on rejection. |
Response 200: the updated ticket. Deciding a ticket that is no longer pending returns 409 conflict; an unknown ticket returns 404 not_found.
POST /v1/physical/actors
Registers (or updates) an actor on the tenant fleet roster. Idempotent on (tenantId, actorIdentity).
Request
| Field | Type | Required | Notes |
|---|---|---|---|
actorIdentity | string | yes | The identity tokens will bind to. |
actorKind | enum | yes | robot | cobot | drone | agv | amr | rov | vehicle | other. |
actorIdentityKind | string | no | Defaults to ieee-802-1ar-devid. |
displayName | string | no | — |
metadata | object | no | Free-form. |
Response 200
GET /v1/physical/actors
Returns { "actors": [ … ] }, most recently registered first.
POST /v1/physical/policy
Uploads a tenant-specific policy. Until a tenant uploads its own, the runtime uses the default policy (deny on emergency-stop, light-curtain breach, human-in-cell).
Request
| Field | Type | Required | Notes |
|---|---|---|---|
policy | any | yes | The policy document (array of clauses under a clauses key — see PLC migration). |
activate | boolean | no | Defaults false. When true, this version becomes active and any previously-active version is deactivated. |
Response 200: { "id": "…", "version": 3, "active": true }. Uploads are versioned per tenant.
GET /v1/physical/policy
Returns the active policy: { "active": { … } | null, "version": 3 | null, "activatedAt": "…" | null }.
Token claims#
The minted token is an RS256 JWT ({ "alg": "RS256", "typ": "JWT", "kid": … }). Standard and physical claims:
| Claim | Notes |
|---|---|
iss | https://api.intended.so |
sub | actorIdentity |
aud | intended-edge-verifier |
iat / exp | Seconds. exp = iat + ceil(deadlineMs / 1000). |
jti | Token id; the audit lookup key. |
intended.version | 2 |
intended.oiCode | Classified OI code. |
intended.tenantId | Issuing tenant. |
intended.actorIdentity / actorIdentityKind | Bound identity; actorIdentityKind is ieee-802-1ar-devid. |
intended.deadlineMs / issuedAtMs / expiresAtMs | Millisecond timing (operationally useful for sub-second TTLs that round to the same exp). |
intended.safeDefault / safetyBit / safetyCitations | Carried from the DAG node and classifier. |
intended.dagNodeId / realTimeTier | From the DAG node. |
intended.physicalStateRef | Ref to the evaluated snapshot, or null. |
intended.operatorTicketId | Set when issued after an approved ESCALATE, else null. |
JWKS signer and edge verifier are not GA
/.well-known/jwks.json serves the physical signer's public key. In dev/sandbox the signer is an ephemeral per-process keypair that rotates on restart — cache short and refresh on kid miss. A KMS-backed signer path exists (INTENDED_PHYSICAL_KMS_KEY_ID). A dedicated edge verifier binary and a native Rust verifier SDK are roadmap — they are not in the repository. Verify against the cloud-served JWKS today.
Wire format#
PhysicalStateValue
A discriminated union on kind:
The unavailable variant carries no attestation; it represents a predicate your provider could not resolve. Policy clauses that require safety-rated input treat unavailable (and untrusted) channels as failing.
safeDefault enum#
ignore is for non-safety-critical actions; it should not appear in any clause covering an OI-29xx (Embodied AI Safety) category.
realTimeTier enum#
Idempotency#
POST /v1/physical/authority-tokens honors an Idempotency-Key header. The same key with the same body within 24 hours replays the original status and body (including stored DENY/ESCALATE responses). The same key with a different body returns 409 conflict. A key first used by another tenant returns 403 forbidden. Recommended: dagNodeId + a monotonic counter.
See also#
- Quickstart — the same flow end-to-end.
packages/contracts/src/physical-ai.ts— the Zod schemas these tables are generated from.- Safety-case writing — how to argue this surface in a deployment safety case.