Re-check the standing lease when a proposal is committed - #105
Merged
b-macker merged 1 commit intoJul 29, 2026
Conversation
agent.propose() gates the standing lease when candidates are generated.
agent.commit() did not gate it at all. Since the deliberation gap between
the two is the entire point of propose/commit, a wall-clock lease can lapse
inside that gap and the commit lands anyway:
PROPOSED turns=0
COMMIT_SUCCEEDED turns=1 <- turn advanced, history appended
SEND_DENIED (lease enforced on send path)
Same handle, same instant, same expired lease: send refused, commit went
through. This is not an agent attack, it is the intended adjudication
workflow, and it is a recurrence of the bug leaseExpiredLocked() was
extracted to fix one layer up. Authority is time-varying; a proposal is not,
so the check belongs on the commit half.
Only the wall-clock half can fire here. Turns cannot advance between propose
and commit, so a turn-based lease that passed propose is still valid at
commit by construction — G-02 holds that claim honest rather than assuming
it, and would catch a gate that had become over-broad.
The refusal mirrors the two existing shapes so callers keep matching on it:
the "step-up" substring when step_up_enabled, since agent.send() renews both
lease halves on a passed challenge; the hard "lease expired" wording when
there is no re-auth path to point at.
Because the re-authorizing send invalidates outstanding proposals, a caller
cannot retry the commit — it has to redo propose, select and commit as a
unit. living-script previously wrapped only propose in its step-up remedy,
so a commit-time expiry would have been swallowed by the outer handler and
shown up as "propose/commit produced nothing" rather than "lease expired".
It now pairs a pre-emptive lease_remaining_seconds renewal with a one-shot
retry of the whole cycle.
Deliberately not added, each traced: hard_stopped and recordAutonomousAction
guard spend and commit makes no API call; reloadIfChanged() would re-baseline
mandate_keywords via onAgentConfigChanged() and score an already-generated
candidate against a mandate it never received, besides dangling config
through the atomic rules_ptr_ swap.
Full suite: 441 tests, 0 unexpected failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELUfjXZvx8kzXo1UJjrAhC
NAAb Governance Report
All governance checks passed! Generated by NAAb Governance Engine v4.0 |
b-macker
marked this pull request as ready for review
July 29, 2026 23:31
5 tasks
b-macker
added a commit
that referenced
this pull request
Jul 30, 2026
#105 established that every check whose truth can change with the passage of time belongs on the commit half, because authority is time-varying and a proposal is not. Two holes in that invariant remained, both on this path. Config changes were not re-checked across the propose->commit gap. commit() deliberately does not call reloadIfChanged() — an accepted reload runs onAgentConfigChanged(), which re-baselines mandate_keywords and would score an already-generated candidate against a mandate it never received, besides dangling `config` through the atomic rules_ptr_ swap. That decision was right, but its consequence was left open: a candidate produced under one configuration could still be committed under another. Proposals now carry the accepted-config generation they were evaluated under, and commit refuses when it has moved — declining to commit under conditions the candidate never saw, rather than re-scoring it under them. reload_count_ and not governance_epoch_: the epoch also moves on pulse verdict transitions, and the pulse sawtooths, so proposals would be voided during normal degraded operation. That stamp was not additive. reload_count_ was a plain int while governance_epoch_ beside it is atomic; it is written under reload_mutex_ but getReloadCount() read it unsynchronised, so reading it from agent worker threads during batch/fan_out would have been a data race. It is now std::atomic<int>. The accessor had no callers, so the change is contained to four sites. Second hole: agentPropose's lease test sat inside `if (cb.step_up_enabled)`, and step_up_enabled defaults to false — so by default propose did not consult the lease at all, while send() hard-throws on an expired lease and commit() checks it unconditionally. Propose was the only entry point where expired authority passed silently. The lease test is now unconditional; the level test stays inside the guard because required_level derives from step_up_at_level and means nothing when step-up is off. No config in the repo combines propose with a lease and step-up off — the two propose configs with step-up off have no lease at all, so the hoisted check is a no-op for them. The change reaches only the configuration nobody was using. Groups H and I cover both. Two mechanics cost real time to find and are recorded in the test: reloadIfChanged() compares mtime at second granularity, so a rewrite in the same second is invisible; and subprocess env is scrubbed, so a re-sign must be passed --signing-key explicitly. Vacuity-checked: disabling the stamp fails H-01 while H-02 still passes, restoring the old nesting fails I-01 while I-02 and I-03 still pass. asan and ubsan both green in CI, which is what would have caught the atomic change being wrong. Full suite: 441 tests, 0 unexpected failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
agent.propose()gates the standing lease when candidates are generated.agent.commit()did not gate it at all. Since the deliberation gap between the two is the entire point of propose/commit, a wall-clock lease can lapse inside that gap and the commit lands anyway.Reproduced against the stub with
standing_lease_seconds: 2— propose, wait 4s, commit:Same handle, same instant, same expired lease:
sendrefused,commitwent through. This isn't an agent attack — it's the intended adjudication workflow, and it's a recurrence of the bugleaseExpiredLocked()was extracted to fix one layer up (propose once checked only the turn-based half). Authority is time-varying; a proposal is not, so the check belongs on the commit half.Changes
src/stdlib/agent_impl.cpp—agentCommit()calls the existingleaseExpiredLocked()inside its existings_agent_mutexblock (no new lock, no lock-ordering change, no I/O), evaluating under the lock and enforcing outside it, matchingagentPropose()'s shape. Refusal mirrors the two existing shapes so callers keep matching on them: thestep-upsubstring whenstep_up_enabled(renewable —agent.send()resets both lease halves on a passed challenge), the hard "lease expired" wording when there's no re-auth path.examples/living-script_extended/src/living-script.naab— the one real consumer, and the exact affected config (standing_lease_seconds: 600+propose_candidates_max: 3). It previously wrapped onlyproposein its step-up remedy, so a commit-time expiry would have been swallowed by the outer handler and surfaced as "propose/commit produced nothing" rather than "lease expired". Now pairs a pre-emptivelease_remaining_secondsrenewal with a one-shot retry. The retry redoes propose → select → commit as a unit, because the re-authorizing send invalidates outstanding proposals by design — retrying just the commit cannot work.tests/governance_v4/test_propose_commit.sh— new Group G.CLAUDE.md— the propose/commit section documented the old behaviour ("commit has no step-up/lease gate of its own") and would have become wrong.Scope
Only the wall-clock half can fire. Turns cannot advance between propose and commit, so a turn-based lease that passed propose is still valid at commit by construction. G-02 holds that claim honest instead of assuming it.
Deliberately not included
Each traced to a concrete failure rather than left out by omission:
reloadIfChanged()— an accepted reload runsonAgentConfigChanged(), which re-baselinesmandate_keywordsand clears the alignment histories; the candidate was generated at propose time under the old mandate, so S11/S20 would score it against an instruction it never received. It also swapsrules_ptr_atomically, danglingconfig.hard_stopped— a run-level budget kill; every existing call site guards a path about to spend.commitmakes no API call and the candidate was paid for at propose time.governance_epoch_equality stamp — it increments on pulse verdict transitions as well as config reloads, and the pulse sawtooths, so proposals would be voided during normal degraded operation.reload_count_stamping remains open — it's the honest remainder of "current governance conditions", but it adds a second refusal mode and deserves its own change.Test Plan
test_propose_commit.sh— 18/18, Groups A–F unchanged, G-01/G-02/G-03 newlease_expired = falsemakes G-01 and G-03 fail while G-02 still passes (G-02 covers the path the gate must not touch)test_split_commit.sh11/11 — commit-path regressiontests/security/test_error_msg_leaks.sh— 874 checks, 0 failures (new error text)bash run-all-tests.sh— 441 tests, 0 unexpected failuresparseclean;checkdiagnostics unchanged at 35 before and after (the two in the edited region are the pre-existing "analyzer doesn't know stdlib modules" class,orchestra/codegen)lease_floor_seconds = 999999):PROPOSE_SELECT|lease_preemptive_reauth|remaining=494→PROPOSE_SELECT|committed=truelease_floor_seconds = 0,standing_lease_seconds: 30):PROPOSE_SELECT|step_up_reauth|attempt=1→PROPOSE_SELECT|committed=trueNote on what the live run does and does not establish: it proves the remedy wiring end to end (refusal → re-authorize → re-propose → commit). The commit-boundary gate itself is proven by G-01 against the stub, which is deterministic. In a live run the 30s lease is already expired by the time the phase executes, so the refusal is raised at
proposeand the unified catch handles it identically — timing an expiry to land precisely inside the propose→commit window is not reliably reproducible, which is exactly why that case belongs in the stub test.Related Issues
None.