Tower's events (POST /api/governance/events)¶
Tower pushes approval events and run stops to hangar on one route. This page is the contract for whoever sends them, Tower's outbox today. The design is in spec 0015 §5 and §12.
Turning it on¶
The route answers 404 until governance is on. It needs both settings below; a secret named but not stored answers 503.
| Setting | Default | Meaning |
|---|---|---|
HANGAR_GOVERNANCE |
off |
Any kind in shadow or enforce (here or in HANGAR_GOVERNANCE_MODES) turns the route on |
HANGAR_GOVERNANCE_WEBHOOK_SECRET |
unset | The vault credential holding the secret Tower signs with: tower-webhook on GKE |
HANGAR_GOVERNANCE_URL, HANGAR_GOVERNANCE_CREDENTIAL |
unset | Where hangar re-reads a decision, and its key for that |
HANGAR_GOVERNANCE_SUSPEND_AFTER_SECONDS |
60 |
How long a tool call waits in its turn before the session is suspended |
The request¶
Tower sends each event as JSON, signed as its aw/webhooks.py signs every delivery:
X-AW-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>
X-AW-Event-Id: <the body's id>
The MAC covers the exact bytes of the body. Header names match in any case. No hangar token is sent or accepted.
| Check | Refused when | Answer |
|---|---|---|
| Signature | missing, or not this secret's | 401 |
| Freshness | t more than 300 s from hangar's clock |
401 |
| Event id | the header names another id than the body | 400 |
| Body | not an event (below) | 400 |
| Replay | an id hangar accepted in the last 48 hours | 200, {"ignored": "duplicate event"} |
Each refusal is audited governance.event_rejected with its reason. A replay answers 200 so Tower's outbox stops retrying it. Accepted ids are kept in hangar's database, so a restart doesn't forget them.
The events¶
kind |
Fields hangar reads | What hangar does |
|---|---|---|
approval.created, approval.decided |
approval.id; approval.decided_by for display |
Re-reads GET /v1/decisions/{id} and acts on that answer |
run.stop |
run.session or corr.session, else run.job or corr.job; run.run; run.stopped_by; run.reason |
Interrupts the run and marks the session stopped |
An approval event is a hint. Its payload never decides anything, so events may arrive late, twice or out of order. A "decided" before its "created" is kept for an hour. hangar reads it once the call it decides starts waiting.
When Tower can't answer the re-read, hangar answers 503 and forgets the event id. Tower's retry then counts as new. A suspended session's approval is also re-read every minute, and at start.
A stop for a session hangar doesn't know answers 200 with {"ignored": "hangar has no such run"}, audited.
Suspend and resume¶
A risky call in an ask turn asks Tower first. Once it has waited the suspend time, hangar does four things:
- It denies the call with "Paused for approval A-9; this turn resumes when it's signed."
- It interrupts the turn at that call.
- It checkpoints the session.
- It releases the sandbox, so the session is suspended and holds no slot.
When Tower allows the call, the session resumes from its checkpoint with a new turn. That turn's driver allows the matching call once. The match is the tool and the hash of its input (args_hash, spec 0015 §10). The call's approval records Tower's receipt; anything else asks as usual. A deny leaves the session suspended, with Tower's reason.
The pre-cleared call reaches the driver in a file signed with the run's key. The driver reads it once and deletes it. A file the agent wrote is ignored.
A job's turn isn't suspended: it waits in place up to the driver's own deadline. So does a turn when events aren't configured.
Stopping a run¶
run.stop goes through hangar's interrupt path, like hangar session interrupt. The audit entry governance.run_stop names who stopped it. The session records the stop, and Tower's approval for that turn is withdrawn. A decision that arrives later doesn't resume it; hangar audits governance.resume_refused. A person may still send the session a new message, which starts a new run.
A complete event¶
{"id": "ev-0c1d6b0f1e2a4b7c9d8e7f6a5b4c3d2e", "kind": "run.stop", "at": "2026-10-27T15:02:11.000Z",
"run": {"session": "ses-77", "run": "run-5d1", "stopped_by": "lee@acme.test", "reason": "wrong repo"},
"corr": {"source": "hangar", "job": "job-9", "session": "ses-77", "turn": 3}}
tests/fixtures/governance/tower_events.json holds an event signed by Tower's own code; tests/test_govevents.py checks hangar verifies it.