Contract
Approval relay contractBeta
The ApprovalChannel HTTP relay — how a held action reaches an approver and how a signed decision travels back. Everything the relay returns is untrusted transport, verified in your environment. The relay's power is deliberately tiny: it can delay a hold, never forge one.
The customer proxy verifies every resolution with verifyApprovalSignature against your pinned keys before promoting it. Machinaut is transport, never the verification authority.
This is the hold half of the govern loop — the receiver side of Lodestar's ApprovalChannel (ADR-0015). A compromised or hostile relay can only change a hold's timing or visibility: a delay resolves to a deny, never a grant. It cannot mint, upgrade, replay, or revive an approval. Why that holds is the trust boundary.
Two surfaces on one path
/v1/approvals carries two different audiences:
- Untrusted transport (customer-proxy facing) — the OSS
ApprovalChannelcontract:announceparks a hold,fetchreturns a resolution,consumespends it. - The approval write-path (approver / explorer facing) — Machinaut's product:
GET /v1/approvals/inboxlists held actions, andPOST …/:request_id/resolverecords a signed Approve / Deny.
announce — POST /v1/approvals
A best-effort push when a hold opens; it lands the hold in the approval inbox. Returns 202. It's ceiling-gated at the source: the customer proxy runs the announce body through the same sensitivity gate as the shipper, so a reason or input above the ceiling never leaves your environment. Because it's best-effort, a failed announce changes only the hold's visibility — never its outcome.
The relay accepts two body shapes, coerced to the same stored request:
- Wrapper envelope —
{ project_id, session_id?, request }, self-contained (shown below). - Bare
ApprovalRequest— the OSS HTTP client's payload, which carries no project. The relay then takesproject_idfrom the route (POST /v1/approvals/{project_id}), thex-lodestar-project-idheader, or a project-scoped key; the optionalsession_idfromx-lodestar-session-id.
POST /v1/approvals
Authorization: Bearer mk_live_…
Content-Type: application/json
{
"project_id": "my-agent",
"session_id": "s-2026-07-06-01",
"request": {
"request_id": "req-42",
"action_id": "act-git-push",
"reason": "push to main is high-risk",
"required_authority": {},
"requested_at": "2026-07-06T09:05:00Z",
"deadline": "2026-07-06T09:20:00Z"
}
}
→ 202 { "accepted": true, "request_id": "req-42" }fetch — GET /v1/approvals/{project_id}/{request_id}
The customer proxy polls this. It returns the bare ApprovalResolution — or 404 while the hold is unresolved, and again once the resolution has been consumed (spent transport is never re-served). What comes back is untrusted: verify it customer-side before acting on it.
GET /v1/approvals/my-agent/req-42
Authorization: Bearer mk_live_…
→ 200 (the bare, UNTRUSTED ApprovalResolution)
{
"request_id": "req-42",
"action_id": "act-git-push",
"kind": "granted",
"approver_id": "user:alice",
"reason": "reviewed the chain — safe",
"at": "2026-07-06T09:10:00Z",
"signature": {
"signer_id": "user:alice",
"payload_hash": "…",
"algorithm": "ed25519",
"signature": "base64…",
"at": "2026-07-06T09:10:00Z"
}
}
→ 404 while the hold is unresolved — or once it has been consumedconsume — DELETE /v1/approvals/{project_id}/{request_id}
After the proxy has verified a resolution and promoted it to a canonical approval.* event in its own log, it spends it here. The record is tombstoned, not deleted — the verdict stays for audit, but fetch stops serving it and the hold leaves the pending inbox. Consuming an absent resolution is not an error.
DELETE /v1/approvals/my-agent/req-42
Authorization: Bearer mk_live_…
→ 204 (tombstoned, not deleted: the verdict stays for audit; fetch stops serving it)The poll paths are never rate-limited
announce and resolve — are rate-limited. fetch, consume, and the inbox poll deliberately are not: a 429 on the poll path would delay an already-signed resolution, and a delayed hold resolves to deny. The live approval loop is never gated.resolve — POST /v1/approvals/{project_id}/{request_id}/resolve
The approver's signed decision (Machinaut's write-path). Resolve is announce-first: the action_id that gets signed comes from the stored request, never the client, so an unknown request is a 404. Input is { kind: "granted" | "denied", approver_id, reason?, at?, signature? }. The presence of signature selects the tier:
Customer-held keys — pure transport
Supply a signature produced in your environment. Machinaut stores it verbatim and does not verify it against pinned keys — that authority stays with you. It does reject a signature that could never verify this document (payload_hash must equal the canonical resolution hash and signer_id must equal approver_id), so the inbox never shows “resolved” over a payload the proxy can't promote — but that's pure document consistency, not a key check.
POST /v1/approvals/my-agent/req-42/resolve
{
"kind": "granted",
"approver_id": "user:alice",
"reason": "reviewed the chain",
"signature": { /* customer-side Ed25519 over the canonical doc */ }
}
→ 200 { "resolution": { … }, "signed_by": "transport" }Managed keys — convenience Planned
Omit signature and Machinaut signs server-side with the operator's managed key. This is a convenience tier, not the posture — a cloud compromise can sign for this tenant, which is exactly the tradeoff customer-held keys avoid. It is a planned surface: the hosted service's posture today is customer-held keys.
POST /v1/approvals/my-agent/req-42/resolve
{ "kind": "granted", "approver_id": "user:alice" }
→ 200 { "resolution": { … }, "signed_by": "managed", "signing_public_key": "…" }A late resolution is a timeout, not a grant
at could be backdated). If the relay receives a resolution after the hold's deadline — or the signed at is itself late — it's a 409 deadline_passed: no customer-verifiable signature is minted, the agent re-proposes, and the safe default (deny) stands.The resolution, and why it's untrusted
The canonical signable document is { request_id, action_id, kind, approver_id, reason?, at }. The stored ApprovalResolution is that document plus an optional Ed25519 signature:
signature.signer_id— the actor that signed; equalsapprover_id.signature.payload_hash— sha-256 of the canonical resolution document.signature.algorithm—ed25519;signature.signatureis the base64 signature bytes.
The customer proxy runs verifyApprovalSignature (from @qmilab/lodestar-policy-kernel, consumed verbatim) against operator-pinned approver keys before it promotes the grant. That verification, and the keys it checks against, live entirely in your environment.
Key tiers
The two tiers are the whole trust story of the relay:
| Tier | What it means | Status |
|---|---|---|
| Customer-held keys | Approver keys never leave your environment; Machinaut only transports the signature. The forgery boundary stays yours — a cloud compromise can't sign. This is the posture. | Beta |
| Managed keys | Machinaut would hold the signing key and sign on your behalf — convenient, but a cloud compromise could then sign for you. Not built: the console refuses it, and every tier is customer-held today. A convenience tier, never the default posture. | Planned |
ApprovalChannel is not ApprovalResolver
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_announce | Body matched neither the { project_id, request } envelope nor a bare ApprovalRequest. |
| 400 | missing_project | A bare announce with no project_id anywhere (route, header, or project-scoped key). |
| 400 | bad_resolution | The resolve body failed validation. |
| 400 | signature_mismatch | A supplied signature can't verify this doc: signer_id ≠ approver_id, or payload_hash ≠ the canonical hash. |
| 403 | project_scope_conflict | An announce names a project that disagrees with the key's pinned project. |
| 404 | not_found | fetch/resolve of an unknown or consumed request (resolve is announce-first). |
| 409 | already_consumed | Resolve of a hold the proxy already spent — terminal, no fresh resolution minted. |
| 409 | deadline_passed | A late resolution: a timeout, not a grant. Resolves to deny; no signature minted. |
| 409 | no_signing_key | Managed-tier resolve with no managed key configured for this tenant/approver. |
Related