0015: The governance decision point¶
Design record
Written 2026-10-16; mostly built. The current behaviour is on job release and landing and governed approvals.
Status: mostly built (2026-10-27): every kind is wired (#172, #173), with events (#190) and the control-plane passport client (#174) and break-glass (KAN-33; its replay to Tower isn't built). Tower's side has the decision API (Tower#13), signed events (Tower#17), attestation receipts (Tower#18) and approver ≠ requester (Tower#19). · Date: 2026-10-16 · Builds on: 0002 (landing), 0011 (triggers and approvals), 0014 (manifest digests), #121 (one approval rule), #123 (intake posture), #129/#130 (taint and its gates) · Counterparts in Tower (a private repository for now): Tower#13 (decision API), Tower#14 (passports), Tower#16 (correlation), Tower#17 (webhooks), Tower#18 (receipts), Tower#19 (approver ≠ requester), Tower#20 (reconciliation), Tower#22 (per-run stop)
Part of a set. Ownership, cross-spec decisions and the combined build order are in docs/README.md; where this spec conflicts with that page, the page wins.
The governed workspace this spec names is Tower, the product's name since October 2026; the spec keeps the name it was written with.
1. The split¶
hangar is the Run plane: it holds sandboxes, credentials, sessions, jobs and landing, and it enforces. Tower is the Govern plane: one policy decision point (Cedar), approvals and their inbox, passports and hash-chained receipts, and it decides. hangar's approvals ask Tower instead of hangar keeping a second approval system.
Tower decides only whether a person or a policy allows something. Everything about how it happens stays in hangar:
| Stays in hangar | Moves to Tower (in enforce) |
|---|---|
Landing mechanics: arming and disarming auto-merge, SHA pinning, the stale-gate, push_mode, branch protection (docs/landing.md §4) |
Whether an approval of this head lands it (who may approve, how many, self-approval rules) |
| Budgets, fairness, the queue, the power schedule | Whether an untrusted job may run |
| Egress enforcement (proxy, NetworkPolicy), the git gateway, intake posture (#123) | Whether a blocked host or a missing secret may be granted |
Taint tracking and which calls are risky (driver.risky, taint_sink) |
Whether a risky tool call in an ask turn may run |
| hangar's local self-approval check (#121), always run first | Approver ≠ requester across platforms (Tower#19), signatures, receipts |
Two rules follow. Tower never makes hangar more permissive than hangar's own enforcement: an allow lets hangar proceed with its own checks; it never skips them. Nothing that goes wrong on the way is ever an allow (§6).
2. The five decisions¶
| Kind (hangar) | Wire kind | Today in hangar | Asked when | hangar's deadline | Idempotency key | Tower tool (Tower#13) |
|---|---|---|---|---|---|---|
job.release |
hangar.job.release |
approvals.decide_now (#121), reached from REST, MCP approve_job, GitHub and Jira /hangar approve |
the job enters awaiting_approval (opens the approval), and each time a person acts on it in hangar (a signature) |
JobApproval.expires_at (HANGAR_JOB_APPROVAL_TTL_HOURS) |
hangar:job:<job>:release |
release_job, resource Job::"<repo>" |
tool.call |
hangar.tool.call |
the driver's ask approvals (driver.Driver.request, steer.answer), only calls driver.risky or taint_sink flag |
the driver writes an approval request | the driver's expires_at (approval_timeout_seconds, default 600) |
hangar:session:<session>:tool:<driver approval id> |
run_command, edit_file, fetch_url, use_tool |
landing.approve |
hangar.landing.approve |
Landing._approved → qualifies (policy approvers, code owners, the taint gate) |
an approving review on the PR's head | none (Tower's ttl, 3 days) | hangar:job:<job>:landing:<head sha> |
merge_pull_request |
egress.request |
hangar.egress.request |
envrequests.decide (egress), egressapi.on_decided |
the agent files a request | the request's own expiry | hangar:session:<session>:egress:<request id> |
egress_request, resource Host::"<host>" |
secret.request |
hangar.secret.request |
envrequests.decide (secret) |
the agent files a request | the request's own expiry | hangar:session:<session>:secret:<request id> |
new: secret_request, resource Secret::"<environment>/<name>" |
Tool calls only reach Tower under approvals: ask and only for the calls hangar's rules flag; auto turns and unflagged calls never leave hangar, which keeps the latency budget (§5) off the common path.
3. The client interface¶
src/hangar/governance.py (built in this spike, not yet wired):
class DecisionPoint(Protocol):
async def decide(self, req: DecisionRequest) -> Decision: ...
async def status(self, approval_id: str, *, kind: Kind) -> Decision: ...
async def withdraw(self, approval_id: str, reason: str) -> bool: ...
class Decision(BaseModel):
outcome: Literal["allow", "deny", "wait"]
reason: str; approval_id: str | None; receipt: str | None; expires_at: float | None
status: str | None # Tower's approval state, or "unavailable" / "local"
rule: str | None
source: Literal["workspace", "local", "fail_closed"]
failure: str | None # why it failed closed: timeout, server_error, auth, malformed, ...
retryable: bool
| Piece | What it does |
|---|---|
DecisionRequest |
kind, idempotency key, requester, optional signer, subject, action, context, correlation, deadline, dry_run; to_wire() / from_wire() for hangar.decision/v1 |
LocalDecisionPoint(ctx) |
Today's rules: without a signer every kind waits for a person; with one, approvals.can_decide for release, landing and egress. No receipts. The source for off, shadow and break-glass |
HttpDecisionPoint(url, credential) |
POST /v1/decisions, GET /v1/decisions/{id}, POST /v1/decisions/{id}/withdraw; 2 s connect, 5 s total; one retry on 401 with a fresh token; a circuit breaker; the fail-closed table |
GovernanceRouter(local, remote, modes) |
What approval paths call: per kind off, shadow or enforce, plus break-glass |
wait_for(dp, decision, kind, deadline) |
Follows a wait with backoff (2 s doubling to 60 s, jittered) or until the event receiver wakes it; fails closed at the deadline |
verify_event(secret, headers, body, seen) |
Checks a pushed event's signature, freshness and id (§12) |
args_hash, idempotency_key |
The shared hash (identical to Tower's aw.store.args_hash) and the key formats of §4.3 |
The protocol differs from the issue's sketch in two places: status takes the kind (so a failure falls back to that kind's row of §6), and withdraw exists (§4.5). The request's requested_by became requester (whose work it is) plus signer (who acted), because "the approver" and "the requester" are different people and Tower must tell them apart (§4.4).
4. Wire contract: hangar.decision/v1¶
JSON over HTTPS, snake_case, times in RFC 3339 (Z or an offset). Within v1 either side only adds optional fields; anything removed or renamed is v2.
4.1 Request¶
POST {tower}/v1/decisions, headers Authorization: Bearer <credential> (§9), Idempotency-Key: <key> (same as the body's), traceparent (§10).
| Field | Required | Meaning |
|---|---|---|
v |
yes | "hangar.decision/v1" |
kind |
yes | one of the five wire kinds |
idempotency_key |
yes | §4.3 |
requester |
no | whose work it is: {person, email, external}. person is hangar's id, email a verified address from the person's linked identity (Tower maps it to its user), external a front door's stable id (github:<numeric id>, jira:<account>, webhook:<source>). Empty for a team job. Accepted only from an agent of kind hangar |
signer |
no | a person who acted at a hangar surface: the same fields plus via (mcp, rest, github, jira, slack) and verdict (approve, reject). §4.4 |
subject |
no | job, session, turn, run, repo (owner/name), environment, pr, base_sha, head_sha, files (≤ 300), author (landing: whose work the PR is), harness_digest, environment_digest, model (concrete id) |
action |
no | tool, args_hash (lowercase hex SHA-256 of the harness's input as canonical JSON), detail (what a person is shown: redacted, ≤ 2000 characters, both ends kept), risky (hangar's reason), host, secret (a name, never a value), scope (session, environment) |
context |
no | untrusted_source, tainted, risk (low, normal, high: landing.risk), packet (docs, tests, code), ai_review ({url, sha, harness, model, verdict}), checks (≤ 50 {name, conclusion, sha, trusted}) |
correlation |
no | traceparent, job, session, turn, run, tool_call_id (§10) |
deadline |
no | hangar's own timeout; the approval's expires_at is never later |
dry_run |
no | shadow: evaluate the rules only; no approval, no cards, no receipt (§7) |
{
"v": "hangar.decision/v1",
"kind": "hangar.job.release",
"idempotency_key": "hangar:job:j-4143a542:release",
"requester": {"external": "github:9912345"},
"subject": {"job": "j-4143a542", "repo": "acme/api", "environment": "hangar-jobs"},
"action": {"detail": "Fix the flaky retry test in tests/test_client.py"},
"context": {"untrusted_source": "github:9912345", "tainted": true},
"correlation": {"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "job": "j-4143a542"},
"deadline": "2026-10-17T14:05:00Z"
}
A tool call relayed from an ask turn:
{
"v": "hangar.decision/v1",
"kind": "hangar.tool.call",
"idempotency_key": "hangar:session:s-77:tool:ap-1a2b3c4d5e6f",
"requester": {"person": "p-12", "email": "dev@acme.test"},
"subject": {"job": "j-9", "session": "s-77", "turn": 3, "run": "r-5d1", "repo": "acme/api",
"environment": "hangar-jobs", "harness_digest": "sha256:9f2c", "model": "claude-opus-4-5-20251101"},
"action": {"tool": "Bash", "args_hash": "0f40a5cddf23051ff00817f5a7674212fa48257ff339c86f0fbedfc2503e88f7",
"detail": "git push origin HEAD:main", "risky": "git push to a protected branch"},
"context": {"tainted": true},
"correlation": {"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01", "job": "j-9",
"session": "s-77", "turn": 3, "run": "r-5d1", "tool_call_id": "toolu_01ABC"},
"deadline": "2026-10-16T15:10:00Z"
}
4.2 Response¶
200 with:
| Field | Meaning |
|---|---|
decision |
allow, deny or wait |
approval |
the approval id; required with wait |
receipt |
the receipt id; required with allow (except dry runs). An allow without one is malformed and fails closed |
expires_at |
when the approval lapses |
status |
the approval's state: allowed (no person needed), approved, blocked, waiting, denied, expired, withdrawn |
rule |
the Cedar rule that decided |
reason |
one line a person can read; hangar posts it where the request came from (redacted) |
poll |
/v1/decisions/<approval> |
{"v": "hangar.decision/v1", "decision": "wait", "approval": "A-123", "expires_at": "2026-10-17T14:05:00Z",
"status": "waiting", "rule": "release-untrusted-job",
"reason": "Waiting for Dana or Lee (maintainers) to sign A-123: an untrusted job needs a maintainer.",
"poll": "/v1/decisions/A-123"}
{"v": "hangar.decision/v1", "decision": "allow", "approval": "A-123", "receipt": "RC-104", "status": "approved",
"rule": "release-untrusted-job", "reason": "Signed by Dana (A-123)."}
How hangar reads deny: status blocked or denied is the policy or a person saying no (a job is rejected with the reason, a tool call denied, a landing not armed, a request denied). expired and withdrawn mean nobody decided: hangar treats them like its own expiry (the job's expire_approvals path, a landing waiting for the next approval) rather than a rejection.
HTTP status codes and what hangar does:
| Code | hangar |
|---|---|
| 200 | the decision, if well formed; otherwise fail closed (malformed) |
| 400, other 4xx | fail closed (contract), not retried, audited governance.contract_error: a bug on one side |
| 401 | one retry with a fresh token, then fail closed (auth), not retried; hangar doctor reports it |
| 403 | fail closed (auth) |
404 on GET /v1/decisions/{id} |
Tower lost or never had it: ask decide again with the same key |
| 409, 410 on withdraw | already closed: done |
| 429 | fail closed (rate_limited), retried with backoff |
| 5xx, whatever the body | fail closed (server_error). Tower answers 503 {"decision": "deny"} on an internal error; hangar reads that as unavailable, not as a policy denial, so a bug in Tower can't reject a job |
4.3 Idempotency¶
One approval per thing a person decides on, keyed as in §2. The rules:
- The same key returns the same approval while it waits.
- A settled
deny(blocked,denied) keeps returning that deny for the approval's ttl: an agent retrying a declined call is told no again. job.releaseandlanding.approvekeep returning their settledallow(with its receipt) for the ttl, so hangar can replay after a restart.- A
tool.callallow is single use (Tower'sclaim_clearance): the first answer that carries it consumes it, and the key is spent. A retry of the same HTTP request after a lost response gets the same answer (Tower keys its response cache on the idempotency key for 10 minutes). expiredandwithdrawnapprovals don't bind the key: the next request opens a new approval.
The key never includes anything a caller controls beyond hangar's own ids. A new PR head is a new landing key, and hangar withdraws the old one.
4.4 Signatures from hangar surfaces¶
A person can act in hangar (/hangar approve on GitHub or Jira, hangar job approve, MCP approve_job, answer_approval, a PR review) or in Tower (Slack, Teams, the web inbox). Both reach the same approval:
- In Tower: Tower records the signature itself and pushes
approval.decided(§12). - In hangar: hangar first runs its own local check (
approvals.can_decide: never your own work, #121), then sendsdecidewith the same idempotency key and asigner. Tower counts it as a signature only if its own rules let that person sign: in the approver group, not the requester (Tower#19, across every platform id), the self-approval annotation for low-risk packets, multi-signature counts. It recordsproof: "hangar:<via>". The answer isallow(rule satisfied, with receipt),wait(needs more signatures), ordenywithstatus: deniedand the reason when the signer may not sign orverdictwasreject.
A signer Tower can't map to a user (no verified email) is not a signature: the answer is wait with a reason saying so, and hangar tells the person to sign in Tower.
4.5 Status and withdraw¶
GET /v1/decisions/{approval} returns the §4.2 shape, for approvals of the calling agent only (anything else is 404). It is the source of truth: hangar acts on it, never on an event's payload (§12).
POST /v1/decisions/{approval}/withdraw with {"v": "hangar.decision/v1", "reason": "..."} closes a waiting approval nobody needs any more: hangar expired the job (expire_approvals), the job was cancelled, a push replaced the PR's head, the turn ended before an answer. Tower closes its cards and writes a withdrawn receipt (its existing Governor.withdraw).
4.6 Dry run¶
dry_run: true evaluates the rules for shadow mode (§7) and answers what it would decide: decision, rule, reason, and no approval, cards or receipt. It doesn't count toward the agent's hourly budget.
5. Timeouts and waiting¶
| Budget | Value | Why |
|---|---|---|
| connect | 2 s | Tower may run outside the cluster |
| decide (total, per request) | 5 s | under every caller's own deadline; a tool call's driver waits up to 600 s |
| status polling | 2 s, doubling to 60 s, ±20 % jitter, until the deadline | a fallback: events normally wake the waiter first |
| circuit breaker | opens after 5 unavailable answers in 30 s, for 30 s | an outage fails 60 sessions closed at once instead of each spending 7 s |
No call blocks while a person decides. decide answers wait at once; hangar keeps the thing pending in its own state and follows the approval:
job.release: the job staysawaiting_approvalwithjob.approval.workspace_approval_id(#172). Anapproval.decidedevent, or the 60 s poll of waiting jobs, re-reads the status;allowqueues the job,denyrejects it.landing.approve:landing.notesays who must sign; the landing is re-evaluated on the event, the next push, or the poll.egress.request,secret.request: the request stays pending with the approval id; the owner sees where to sign.tool.call: the turn's pending approval shows Tower's approval id and link. Then:- With events on and a harness that can checkpoint, once the call has waited
suspend_after(60 s; #190 owns the value) hangar denies it with "Paused for approval A-9; this turn resumes when it's signed", lets the turn end at that tool boundary, checkpoints, and releases the sandbox. The session isawaiting_approval. - On
approval.decidedhangar re-reads the status.allowresumes the session from its checkpoint and pre-clears that one call in the driver: hangar passes(tool, args_hash, receipt), and the driver allows the first matching call once, records the receipt on the approval, and asks as usual for anything else. The resumed turn doesn't ask Tower again, so Tower's run id changing with the turn number doesn't matter.denyleaves the session idle with the reason as an event (#190). - Without events, or for a harness that can't checkpoint, the turn waits as
askturns do today, up to the driver's own deadline, then the driver denies. This replaces the hook's fixed 540 s poll for hangar sandboxes; it isn't another one.
6. Fail closed¶
What each kind does when Tower can't answer. retryable failures are retried in the background (the 60 s poll for pending things; within the driver's deadline for a tool call); none of them ever becomes an allow.
| Condition | job.release |
tool.call |
landing.approve |
egress.request, secret.request |
|---|---|---|---|---|
| unreachable, connect timeout, no answer in 5 s | stays awaiting_approval; the issue or ticket gets "approval service unavailable" once per outage |
denied with "approval service unavailable"; asked again while the driver's deadline allows, then the driver's timeout denial | not armed; landing.note = "approval service unavailable" |
stays pending; the owner is told once |
5xx (including 503 deny), 429 |
same | same | same | same |
| 401 after refresh, 403 | same, and hangar doctor fails the governance check; not retried until the credential changes |
denied; not retried | not armed | stays pending |
| 400, other 4xx, malformed answer, allow without receipt | same; audited governance.contract_error; not retried |
denied | not armed | stays pending |
| circuit open | same, without a network call | denied at once | not armed | stays pending |
wait past hangar's deadline |
expire_approvals drops the job as today, and hangar withdraws the approval |
the driver denies as today; hangar withdraws | stays approved-but-unarmed until a new approval | expires as today; hangar withdraws |
| unknown approval (404 on status) | decide again with the same key |
same | same | same |
Self-approval is refused locally before any of this (#121, #160): Tower being down never turns a refused self-approval into a pending one.
7. Modes and break-glass¶
HANGAR_GOVERNANCE=off|shadow|enforce sets the default; HANGAR_GOVERNANCE_MODES overrides per kind (tool.call=shadow,landing.approve=enforce or JSON). The default is off.
| Mode | Who decides | Tower |
|---|---|---|
off |
hangar's local rules, as today | not asked |
shadow |
hangar's local rules | asked the same question as a dry run, in the background, never waited for; a different outcome is audited governance.disagree (with both rules and reasons) and counted in hangar_governance_disagreements_total{kind}. HANGAR_GOVERNANCE_SHADOW_SAMPLE samples tool calls (default 1.0 for the rest); at most 8 shadow requests in flight, extra ones are dropped rather than queued |
enforce |
Tower, failing closed (§6) | asked; its allow (with receipt) is required |
A disagreement is any different outcome. Local allow against remote wait ("Tower would have asked someone") is the common one while policies are tuned.
Break-glass. hangar governance local --until <time> --reason <text>, by an admin, confirmed by a second admin, at most 24 hours, audited governance.break_glass (start, confirm, end). While active, kinds in enforce are decided by the local rules; each such decision is queued and replayed to Tower as a record once it answers again (an open item for Tower, §16), so its receipts have no gap. Break-glass never applies to off or shadow kinds (they're local already) and ends on its own.
hangar refuses to start with shadow or enforce and no HANGAR_GOVERNANCE_URL, and hangar doctor checks the URL, the credential and the webhook secret.
8. Offline and cluster down¶
| Situation | Behaviour |
|---|---|
| Overnight power-down (sandbox pools off 01:00–10:00 ET; control plane and Tower up) | Job release and landing decisions work as by day; released jobs queue for 10:00. No tool calls run. Events for suspended sessions are applied when they arrive; the resumed turns start at power-up |
| Tower down | §6. Pending things stay pending; nothing is allowed; break-glass if it lasts |
| hangar control plane down | Tower retries webhook deliveries for 24 h (Tower#17's outbox). On start hangar re-reads the status of every pending item (jobs awaiting approval, unarmed landings, pending requests, suspended sessions) before taking new work |
| Tower can't reach hangar (webhooks fail), hangar can reach it | Polling (§5) settles everything; slower, not wrong |
| Both down | Nothing moves; both catch up from their own state |
| Laptop or single-machine install with no Tower | off (the default): LocalDecisionPoint only |
| An answer from Tower arrives after hangar gave up (deadline passed, job dropped) | hangar withdraws the approval if it is still waiting; an allow for something already dropped is recorded and ignored |
9. Identity¶
Control-plane calls (every kind in §2 is asked by hangar's control plane, not by a sandbox):
- Phase 1, a service key.
HANGAR_GOVERNANCE_CREDENTIALnames a secret in hangar's vault; Tower stores its hash for an agent of kindhangar, scoped to/v1/decisions*(Tower#13). It can assert any requester and signer, so it is as powerful as hangar's admin token and is rotated like one. - Phase 2, a passport (#174, Tower#14). hangar mints itself a token (
iss= hangar's OIDC issuer,sub: hangar:control-plane,aud= Tower's/oauth/token, 5 minutes; a newCloudCredsmethod, sincemintneeds a sandbox today), exchanges it (RFC 8693) for a 15-minute passport, caches it until 60 s before expiry, and re-exchanges on a 401 (Credential.token(fresh=True)). Tower trusts that issuer for agenthangarwithclaims: {sub: "hangar:control-plane"}. The service key goes.
The requester and signer still come from the body, signed by hangar's TLS-authenticated identity; they are accepted only from the hangar agent.
Sandbox calls are a different path: an environment with a governance: block installs a hangar-owned Claude Code hook that exchanges the sandbox's token (sub: hangar:env:<env>) for a passport naming the person, and calls /hooks/claude-code directly (#174, #175, Tower#14). A turn uses exactly one path. With the managed hook, Tower sees every call through /hooks, and hangar's driver answers only from its local deny-risky rules as a guard rail, never relaying to Tower as well; without it, the driver relays flagged ask calls as tool.call (§5). Otherwise one call would raise two approvals.
10. Correlation¶
Every request carries hangar's ids and the W3C trace context of the span that asked:
- the
traceparentheader andcorrelation.traceparent: for a tool call, the turn'sinvoke_agentspan (the sameTRACEPARENTthe agent runs with,fleet.py); for a release or landing, the job's span; correlation:job,session,turn,run(hangar's run id),tool_call_id(the harness's id for the call: Claude Code'stool_use_id).
Tower stores them on its run and receipts (corr, Tower#16; its run id becomes R-h-<session>-<turn>, or R-h-job-<job> for a release with no session) and echoes corr in events. hangar records each decision as a governance.decide span with hangar.governance.kind, .mode, .outcome, .approval, .receipt, .source and .failure, and puts the receipt on the approval's span so reconciliation can match exactly (#176, Tower#20).
args_hash is the one value both sides compute: lowercase hex SHA-256 of json.dumps(input, sort_keys=True, separators=(",", ":"), default=str) over the harness's tool input as the harness sent it, before any clipping. hangar sends it; Tower records it on the receipt for hangar.tool.call (it doesn't recompute it from detail, which is clipped); hangar's exported execute_tool span carries the same value as gen_ai.tool.call.arguments.hash.
11. Receipts¶
In enforce, an allow without a receipt is refused (§4.2). hangar keeps the id where the decision lives and shows it:
| Kind | Stored on (added by) | Shown |
|---|---|---|
job.release |
JobApproval.receipt, .workspace_approval_id (#172) |
job page, the issue comment, the review packet |
landing.approve |
LandingState.receipt (#172) |
the review packet's landing rule line |
tool.call |
Approval.receipt on the turn (#173) |
the turn's approvals, the trace span |
egress.request, secret.request |
EnvRequest.receipt |
the request |
For job.release and landing.approve hangar also sends subject.harness_digest, environment_digest and the concrete model from the turn's manifest (0014), and context.checks with trusted set for hangar's own verification, so Tower's receipt carries the attestation envelope (aw.attest/v1, Tower#18). hangar links to GET /v1/receipts/{id}/attestation; it doesn't verify the chain itself. Local, shadow and break-glass decisions carry no receipt (break-glass decisions get one when replayed).
12. Events from Tower¶
One inbound route, POST /api/governance/events, for everything Tower pushes (it replaces the separate /api/governance/decisions callback #172 sketched; #190 builds it):
- Signature (Tower#17):
X-AW-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>,X-AW-Event-Id. The secret isHANGAR_GOVERNANCE_WEBHOOK_SECRETin hangar's vault, one per subscription. Rejected and audited: no or bad signature,tmore than 300 s off, an event id seen before (ids are kept 48 h, past Tower's 24 h retry window), a malformed body. - Approval events are hints.
approval.createdandapproval.decidedonly make hangar re-readGET /v1/decisions/{approval}with its own credential and act on that answer. A forged, replayed, stale or reordered event can at most cause a status read, so out-of-order delivery ("decided" before "created") needs no special handling. run.stop(Tower#22) interrupts the run through hangar's existing interrupt path, is audited with who stopped it, and marks the session so that no laterapproval.decidedresumes it. It only removes capability, so it is acted on once its signature checks out. Tower never holds a hangar admin token for this.- Events that arrive during the power-down are applied to hangar's state at once; anything that needs a sandbox waits for power-up.
{"id": "ev-01HF3", "kind": "approval.decided", "at": "2026-10-16T15:02:11Z",
"approval": {"id": "A-123", "status": "approved", "idempotency_key": "hangar:job:j-4143a542:release",
"decided_by": "dana@acme.test", "receipt": "RC-104", "expires_at": "2026-10-17T14:05:00Z"},
"corr": {"source": "hangar", "job": "j-4143a542", "session": null, "turn": null}}
13. Security notes¶
- The
hangaragent's credential can assert requesters and signers. Phase 1 scopes the key to/v1/decisions*; phase 2 replaces it with a 15-minute passport. Either way it never reaches a sandbox. - A signer asserted by hangar is only as good as hangar's front door: GitHub numeric ids, linked accounts (people.py), never logins. Tower records
proof: hangar:<via>so an auditor can tell those signatures from ones made in Tower. action.detailis content (a command, a file path). It is redacted (broker.redact), clipped to 2000 characters with both ends kept (driver.shown), shown on the approval card, and forgotten by Tower once decided (its AD-6). Receipts keep onlyargs_hash. Events carry ids and hashes only.- hangar posts Tower's
reasonon public issues; it is redacted on the way out like any other text hangar writes there.
14. Rollout¶
| Phase | What turns on | Owner issues | Exit |
|---|---|---|---|
| 0. This spike | spec, governance.py, contract tests; mode off everywhere |
#171 | merged; the Tower issues reference this schema |
| 1. Shadow, control plane | job.release and landing.approve in shadow, service key, dry runs |
#172, Tower#13 | 7 days with every disagreement explained; p95 decide < 500 ms; unavailable < 1 % |
| 2. Identity and events | control-plane passport; /api/governance/events live; still shadow |
#174, #190, Tower#14, Tower#17 | the service key deleted; events verified in production; restart replay tested |
| 3. Enforce, control plane | job.release, landing.approve in enforce; break-glass rehearsed once |
#172 | a week with no disagreements, then hangar-local approval for these kinds is deprecated (kept for off and break-glass) |
| 4. Tool calls | tool.call shadow (sampled), then enforce under ask; suspend and resume; trace reconciliation |
#173, #190, #176, Tower#20 | suspend/resume tested across a power-down; reconciliation shows no unreceipted hangar calls |
| 5. Requests and attestation | egress.request, secret.request in enforce; attestation on landing receipts |
Tower#18 |
Any phase rolls back by setting the kind back to shadow or off; nothing is migrated, and receipts already written stay.
15. Where Tower's API differs from what hangar needs¶
Each item is proposed as a comment on the Tower issue that owns it.
| # | Today, or as Tower's issue drafts it | What hangar needs | Issue |
|---|---|---|---|
| 1 | Only /hooks/claude-code (a hook's tool call) and /agentcore/decide; nothing for a decision with no tool call |
POST /v1/decisions, GET /v1/decisions/{id} |
Tower#13 |
| 2 | Four kinds: hangar.job.release, hangar.tool, hangar.landing, hangar.egress |
Five, named hangar.<kind>: hangar.job.release, hangar.tool.call, hangar.landing.approve, hangar.egress.request, hangar.secret.request (new tool secret_request) |
Tower#13 |
| 3 | No way for a caller to bring a signature; people sign only in Slack, Teams or the web | signer (§4.4), from the hangar agent only, judged by decide()'s own rules, recorded as proof: hangar:<via> |
Tower#13, Tower#19 |
| 4 | An exception answers 503 {"decision":"deny"} |
Fine, but documented as advisory: hangar treats every 5xx as unavailable (§4.2) | Tower#13 |
| 5 | "Block, then ask again" when deadline is under 2 s: a deny with the approval id |
For /v1 always answer wait; hangar follows the approval itself (§5). Keep block-then-ask for hook callers |
Tower#13 |
| 6 | "Same key → same final decision" | Single-use allows for hangar.tool.call; expired and withdrawn approvals don't bind the key; a 10-minute response cache for retried requests (§4.3) |
Tower#13 |
| 7 | No dry run | dry_run: true: rules only, no approval, cards or receipt, not counted in the hourly budget (§4.6) |
Tower#13 |
| 8 | withdraw() exists internally, no route |
POST /v1/decisions/{id}/withdraw (§4.5) |
Tower#13 |
| 9 | Hook answers use expiresAt (camelCase) and no state |
snake_case expires_at (RFC 3339), plus status and rule (§4.2) |
Tower#13 |
| 10 | A tool call's action.input (raw); receipts hash Tower's mapped arguments (from_hook: run_command with the command clipped to 2000 characters and project added) |
action.args_hash from hangar, recorded on the receipt for hangar.tool.call, and action.detail for the card (§10). Otherwise no receipt can match hangar's span |
Tower#13, Tower#20 |
| 11 | requester_known is false for an outsider; rules on writes may block on it |
release_job must ask maintainers even when the requester is an outsider (requester.external only): that is the case it exists for |
Tower#13 |
| 12 | ai_review is a URL string |
an object {url, sha, harness, model, verdict}: Tower#19 needs the harness and model to judge independence, and the sha to drop a review of an older head |
Tower#13, Tower#19 |
| 13 | Passports are minted only with aud = <origin>/mcp, and check() refuses any other audience |
/v1/decisions and /hooks/* must accept the passport hangar holds: one service audience for every route, or a list |
Tower#14 |
| 14 | Federation example pins sub: hangar:env:<env> with requester_claim (sandboxes) |
also a trust for agent hangar (kind hangar) with sub: hangar:control-plane and no requester claim; and hangar's tokens carry person, not a verified email, until #174 adds one (the hangar_people map is the fallback) |
Tower#14 |
| 15 | Webhooks signed with HMAC (Tower#17); hangar's #172 and #190 drafts said "JWKS / EdDSA" | HMAC as Tower#17 specifies, because approval events are hints that hangar re-reads (§12). hangar's drafts follow this spec | Tower#17 |
| 16 | Webhook payload: approval and corr |
also the decision kind (hangar.job.release, ...); a run.stop event kind on the same subscription |
Tower#17, Tower#22 |
| 17 | Per-run stop calls hangar's REST interrupt with a stored hangar token | a signed run.stop event to /api/governance/events; no hangar token in Tower |
Tower#22 |
| 18 | Reconciliation reads gen_ai.tool.call.arguments (raw) and matches the receipt by the span's tool name |
match by corr.tool_call_id first, then gen_ai.tool.call.arguments.hash (bare lowercase hex, as receipts store it) and job/session; hangar doesn't export raw arguments by default |
Tower#20 |
| 19 | corr is {source, job, session, turn, trace_id, span_id, tool_call_id} |
add run (hangar's run id: one turn can have more than one run after a crash resume) |
Tower#16 |
| 20 | Attestation is filled from digests |
hangar sends the digests and model in subject (subject.harness_digest, .environment_digest, .model); checks in context.checks with trusted. Either name works if both sides agree; this spec uses subject |
Tower#18 |
| 21 | Break-glass decisions taken while Tower was down have no receipt | a "record" request (POST /v1/decisions with recorded: {decided_by, rule, at}) that writes a receipt with proof: hangar-break-glass and opens no approval |
Tower#13 (open item) |
16. Open items¶
- Break-glass replay (§7, item 21): the record request's shape is agreed only in outline.
- Approval ttl for landings: Tower's
merge_pull_requestttl is 3 days; hangar has no landing deadline today. hangar sends none for now. - Who signs in Tower for a team job (no person):
requesteris empty and onlycontext.untrusted_sourceis set; maintainers sign. Fine for release; for landing, the policy approvers decide in hangar today, so their mapping to a Tower group needs agreeing per repo. - Multi-signature from hangar surfaces: two
/hangar approvecomments by different people each add a signature; the second gets theallow. Tested on Tower's side once Tower#13 lands.
17. What the contract tests pin¶
tests/test_governance.py, against a fake Tower (httpx.MockTransport):
- each row of §6 for every kind: unreachable, timeouts, 500/502/503 (including 503
{"decision":"deny"}), 429, 401 after a refresh, 403, 400, a malformed body, an allow without a receipt, an open circuit: none is everallow; - the 401 retry with a fresh token; the circuit opening and closing;
waitfollowed by status polling untilallow, a webhook waking the waiter, and the deadline failing closed;- shadow never changing the outcome and reporting disagreements; enforce using Tower; break-glass needing two admins and 24 h at most;
args_hashequal to Tower's own function;- the JSON examples in this document parse with the client's models, and an event signed as Tower#17 specifies verifies (stale, replayed and tampered ones don't).