Make propose and commit agree on time-varying authority - #107
Merged
b-macker merged 1 commit intoJul 30, 2026
Conversation
#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 is not additive. reload_count_ was a plain int while governance_epoch_ beside it is atomic; it is written under reload_mutex_ but getReloadCount() reads 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. Refusal wording matches the two existing shapes so callers keep matching on them. 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, which is the hole. 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 the 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. 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 30, 2026 23:43
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
#105 established the invariant: 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 the propose/commit path.
A1 — proposals now carry the config generation they were evaluated under
agent.commit()deliberately does not callreloadIfChanged()— traced and rejected in #105, because an accepted reload runsonAgentConfigChanged(), which re-baselinesmandate_keywordsand would score an already-generated candidate against a mandate it never received, besides danglingconfigthrough the atomicrules_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 are now stamped with
reload_count_at propose time 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 notgovernance_epoch_: the epoch also increments on pulse verdict transitions (governance_engine.cpp:6906), and the pulse sawtooths, so proposals would be voided during normal degraded operation.This was not additive.
reload_count_was a plainint(governance.h:3478) whilegovernance_epoch_beside it isstd::atomic<int>. It is written underreload_mutex_butgetReloadCount()read it unsynchronised — reading it from agent worker threads duringbatch/fan_outwould have been a data race. It is now atomic. The accessor had no callers, so the change is contained to four sites.A2 — propose consults the lease even with step-up disabled
agentPropose's lease test sat insideif (cb.step_up_enabled), andstep_up_enableddefaults to false (governance.h:1600). So by default propose did not consult the lease at all, whileagent.send()hard-throws (agent_impl.cpp:1461) andagent.commit()checks it unconditionally (#105). Propose was the only entry point where expired authority passed silently.The lease test is now unconditional. The level test stays inside the guard —
required_levelderives fromstep_up_at_level, so it is intrinsically about step-up and means nothing when step-up is off. Refusal wording matches the two existing shapes so callers keep matching on them.Regression surface is empty, verified. Only four configs in the repo enable propose:
test_split_commit.shtest_absorption_degenerate.shtest_propose_commit.shliving-script_extended/src/govern.jsonNothing combines propose + a lease + step-up off. The two with step-up off have no lease, so
lease_configuredis false and the hoisted check is a no-op for them. The change reaches only the configuration nobody was using — which is the hole.Two mechanics that cost real time to find
Both are recorded in the test, since anyone writing a similar case will hit them:
reloadIfChanged()compares mtime at second granularity (governance_config.cpp:3753). A rewrite landing in the same second as the initial load is invisible — the first attempt at H-01 silently did nothing.--signing-keyexplicitly;NAAB_SIGNING_KEYdoes not survive into the child. This is the same workaroundliving-script.naabalready uses.Test Plan
test_propose_commit.sh— 23/23, Groups A–G unchanged, H and I newstep_up_enablednesting restored → I-01 fails, I-02 and I-03 still pass (the no-op and anti-regression controls survive)tests/security/test_error_msg_leaks.sh— 874/0 (new refusal text)bash run-all-tests.sh— 441 tests, 0 unexpected failuresreload_count_atomic change being wrong. Worth watching on this PR specifically.Honest scoping
The reload stamp will rarely fire in
living-script_extended— nothing between propose,select_admissibleand commit triggers a reload there, and the one path that does send (the #106 re-auth retry) already invalidates the proposal. This is an invariant, not a detector. It earns its place in multi-agent scripts where a sibling agent's send, a polyglot block (polyglot.cpp:158) or the VM (vm.cpp:2815) reloads mid-gap.PR B (example taint/integrity coverage) is deliberately separate and does not depend on this.
Generated by Claude Code