Skip to content

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 ApprovalChannel contract: announce parks a hold, fetch returns a resolution, consume spends it.
  • The approval write-path (approver / explorer facing) — Machinaut's product: GET /v1/approvals/inbox lists held actions, and POST …/:request_id/resolve records 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 takes project_id from the route (POST /v1/approvals/{project_id}), the x-lodestar-project-id header, or a project-scoped key; the optional session_id from x-lodestar-session-id.
announce (wrapper form)http
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.

fetchhttp
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 consumed

consume — 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.

consumehttp
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

Only the state-creating writes — 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.

resolve (customer-held)http
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.

resolve (managed)http
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

The deadline is gated on server receipt time (a client-supplied 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; equals approver_id.
  • signature.payload_hash — sha-256 of the canonical resolution document.
  • signature.algorithm ed25519; signature.signature is 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:

TierWhat it meansStatus
Customer-held keysApprover 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 keysMachinaut 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

This relay is the ApprovalChannel — untrusted HTTP transport between your proxy and the inbox. It is not the ApprovalResolver, the trusted, in-process OSS seam that actually decides a hold inside Lodestar. Never conflate them: the relay carries bytes; the resolver — in your environment — confers trust.

Errors

StatusCodeWhen
400bad_announceBody matched neither the { project_id, request } envelope nor a bare ApprovalRequest.
400missing_projectA bare announce with no project_id anywhere (route, header, or project-scoped key).
400bad_resolutionThe resolve body failed validation.
400signature_mismatchA supplied signature can't verify this doc: signer_id ≠ approver_id, or payload_hash ≠ the canonical hash.
403project_scope_conflictAn announce names a project that disagrees with the key's pinned project.
404not_foundfetch/resolve of an unknown or consumed request (resolve is announce-first).
409already_consumedResolve of a hold the proxy already spent — terminal, no fresh resolution minted.
409deadline_passedA late resolution: a timeout, not a grant. Resolves to deny; no signature minted.
409no_signing_keyManaged-tier resolve with no managed key configured for this tenant/approver.

Related

The record half of the loop is the ingest contract. Every route with its status is in the API reference; the safety argument is the trust boundary.