Skip to content

feat(audit): seal the turns Garrison's own gates refuse - #52

Merged
rrrodzilla merged 1 commit into
mainfrom
worktree-seal-refused-turns
Aug 30, 2026
Merged

rrrodzilla merged 1 commit into
mainfrom
worktree-seal-refused-turns

Conversation

@rrrodzilla

Copy link
Copy Markdown
Contributor

The case #28 opened with

Turn entries closed most of that issue. They did not close the case it opened
with. Garrison's admission gates are asked before the prompt loop, so a turn
refused for a lapsed seat, an unreachable plane, a full shipping backlog, or an
interrupted predecessor never reached the runtime, and nothing wrote it down.

An install refused fifty times in an afternoon still left a trail
indistinguishable from an install nobody touched. Telling those two apart is
most of what the trail is for.

acton-ai 0.37.0 (Govcraft/acton-ai#17) added the public single-writer path this
needed, so the gate now seals before it returns.

What changed

agent/src/thread.rs:958:

if let Admission::Refuse(refusal) = admission::admit(&setup.gates, &request).await {
    seal_refusal(setup, turn_id, &refusal, &messages, opening).await;
    return TurnResult::Refused(refusal);
}

seal_refusal counts the prompt the way the admitted path does, from the last
user message, and passes the same conversation checkpointed would have used.
The prompt is counted, never copied. An install with no trail configured is
skipped rather than logged at, because it has nothing to seal into.

TurnRefusal::decision() is new: the stable lowercase word the trail records
(seat, plane_unavailable, policy, audit_shipping, audit_degraded,
store_unavailable, turn_interrupted, gate_unreachable). Display stays
the prose a human reads in an error and can be reworded whenever it reads
badly. An auditor's saved query filters on the slug, so rewording the prose
never breaks it.

Sealing does not change the verdict

A refusal that cannot be recorded is still a refusal. The turn was already
being turned away, and failing louder would not admit it. The failure goes to
the log, and the audit health _garrison/status already reports is what an
operator acts on.

Proof

a_turn_the_daemon_died_in_blocks_the_session_until_it_is_settled already
produced a genuine gate refusal through a real daemon. It now also reads the
trail that daemon left and asserts the refusal was sealed: entry_kind of
turn, a decision of turn_interrupted, its reason, a prompt_size_bytes of
27, and that the prompt text itself appears nowhere in the file. The append is
durable and awaited before the refusal returns, so it is on disk by the time
the client sees the error code.

Pins

Both crates moved to =0.37.0 via cargo add. AuditEntry grew a
context_sources field, skipped when empty on both the entry and the hash
pre-image, so the fixture skeleton initializes it and no existing byte moved.
Projecting that field into the plane is deliberately not in this PR.

Test status

cargo nextest run --workspace --release --locked --no-fail-fast:
1290 passed, 2 failed, 1 skipped.

Both failures are on main already and are untouched by this branch:

  • tui::composer::tests::an_empty_composer_shows_a_hint_with_the_caret_after_the_prompt
  • tui::compositor::tests::disabling_color_preserves_text_and_non_color_attributes

Both assert on colors that 499fe55 Improve terminal text contrast changed
without updating the assertions. This branch touches no TUI code.

https://claude.ai/code/session_019QLkGsybQkgMocxu8eMsez

Turn entries closed most of the gap, but not the case the issue opened with.
The admission gates are asked before the prompt loop, so a turn refused for a
lapsed seat, an unreachable plane, a full shipping backlog, or an interrupted
predecessor never reached the runtime, and nothing wrote it down. An install
refused fifty times in an afternoon still left a trail indistinguishable from
an install nobody touched, which is the one thing the trail exists to tell
apart.

acton-ai 0.37.0 adds the public single-writer path this needed, so the gate
now seals before it returns. The record is the same metadata a turn entry
already carries: the stable reason, the rendered refusal, and the prompt's
byte count. The prompt itself is counted, never copied.

`TurnRefusal::decision` is the word the trail records. Display renders a
refusal for a human reading an error and may be reworded whenever it reads
badly; an auditor's saved query must not break when it is. Every arm answers a
fixed lowercase word, and an arm added later adds a word rather than changing
one.

Sealing does not change the verdict. A refusal that cannot be recorded is
still a refusal: the turn was already being turned away, and failing louder
would not admit it. The failure goes to the log, and the audit health status
already reports is what an operator acts on. An install with no trail
configured is skipped rather than logged at, since it has nothing to seal
into.

Proved end to end against a real daemon: the interrupted-turn test now reads
the trail it left, and finds the refusal sealed with its decision, its reason,
and a prompt byte count whose text appears nowhere in the file.

Claude-Session: https://claude.ai/code/session_019QLkGsybQkgMocxu8eMsez
@rrrodzilla
rrrodzilla force-pushed the worktree-seal-refused-turns branch from 407ae5f to 9962ac2 Compare August 30, 2026 23:25
@rrrodzilla
rrrodzilla merged commit ec500d1 into main Aug 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant