Evaluator and executor
The service that scores an agent cannot release a hold on it. That is not a code review convention. It is two services, two database roles, and a test that tries the forbidden write and asserts failure.
Two services
| Service | What it does | Database role | Key scope it accepts |
|---|---|---|---|
vigil-api | Scores events, evaluates the gate, appends event ledger records, reads review state | vigil_api | events:write, read, admin, platform |
vigil-gate | Lists holds; releases, rejects or escalates them; appends review ledger records | vigil_gate | review (or admin) |
vigil-api has no route that writes reviewed_by, reviewed_at, review_outcome or review_note. A CI test walks its openapi.json and fails on any non-GET method under /enforcement or /holds.
vigil-gate runs as its own deployment with its own database URL. It never receives the API key, the API's database URL, or any model key.
Two database roles, proven at the database
| Role | May | May not |
|---|---|---|
vigil_api | INSERT events, scores, actions, ledger records; UPDATE agents, keys, policies | UPDATE enforcement_actions; DELETE anything |
vigil_gate | UPDATE enforcement_actions and ledger_head; INSERT ledger_records and webhook_deliveries | INSERT behavioral_events; write any other table |
vigil_ro | SELECT | Write anything |
The grants live in an Alembic migration. A CI test connects as each role: vigil_api UPDATE on enforcement_actions is denied, vigil_gate UPDATE succeeds, vigil_gate INSERT into behavioral_events is denied, vigil_ro cannot write. The denial was reproduced against production before the database cutover. This is decision rule 8 in the repo.
What the agents can reach
A governed agent holds a key scoped to events:write and usually read. It can post events and read scores. It cannot create keys, change thresholds, reach the gate, or mark itself authorized in the registries. VIGIL's own agents (the Investigator) get read tools and one propose tool; a CI test fails on any tool named release, reject, escalate, patch, delete, update or insert.
The review contract
GET /holds scope review pending actions in your org
POST /holds/{action_id}/release scope review body { "reviewer": "JAS", "note": "..." }
POST /holds/{action_id}/reject
POST /holds/{action_id}/escalate
reviewer is required. A decision is terminal: the second call on the same action is 409. Every decision appends a review record under the org's ledger_head lock and queues a review.<outcome> webhook. The gate's base URL is its own domain, published in the repo's operations guide; the console reaches it with a separate review key.
See the separation from the outside
The API exposes only reads of review state. This call lists what is waiting for a human:
curl -sf -H "Authorization: Bearer $VIGIL_KEY" "$VIGIL_URL/enforcement/pending"
And this one, sent to the API instead of the gate, must fail with 404 or 405 because the route does not exist there:
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $VIGIL_KEY" "$VIGIL_URL/holds/1/release")
test "$code" = "404" -o "$code" = "405" && echo "api has no release route ($code)"