Repository navigation
Bind push authorization to Git's reference transaction - #30
Conversation
pre-receive consumed approvals and committed before Git moved the ref, so an update Git refused afterwards (another hook, a lost ref lock) still spent its approvals, and a claim release, takeover, or handoff in that gap let a push land on authority that no longer held. - pre-receive (#18's checks, unchanged) now records each validated ref update in ref_updates (migration 006) with its derived paths and governed operations, and consumes nothing. - A managed reference-transaction hook re-validates every recorded update at `prepared`, while Git holds the ref locks and can still abort, and consumes the approvals there; any failure aborts the update. It reuses the paths pre-receive derived, so operation hashes match what was approved even after an earlier ref of the same push made the commits known. `committed`/`aborted` record the outcome; an abort after `prepared` restores the consumed approvals unless a newer live instance exists. - Only unidentified transactions pass through as local maintenance; an identified one without a fresh validation, or with the store missing, fails closed. Symbolic refs such as HEAD are left to their target line. - pre-receive refuses pushes when the transaction hook is missing, modified, or not executable; hook install writes both hooks, inspects both before writing either, and requires Git 2.28. - doctor reconciles updates stuck in `prepared` from the ref itself and never guesses when the ref has moved or is still locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Review on #28: the schema fingerprint only ignored whitespace around brackets and commas, so an object recreated with lowercase keywords or different operator spacing (role<>'' versus role <> '') was reported as altered and every command refused the store. SQL is now compared as a token sequence: keyword and bare-name case, whitespace, and comments are ignored, while quoted literals and quoted identifiers stay exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
|
This is a handover from another session. I didn't see that #10 had been closed, and rebuilt the same transaction boundary on #10's branch. It ended up close to this PR's design: migration All of it is at Tests worth porting
Follow-up once #29 merges Generated by Claude Code |
From the session that rebuilt this boundary on #10's branch (44a6b5f): - A force push refused by receive.denyNonFastForwards, which Git checks after pre-receive, keeps its approval and the retry uses it. On main the approval is consumed and the branch does not move. - An update needing two approvals (protected ref and protected path) consumes both at `prepared`, records both in approval_ids, and restores both on `aborted`. - A non-atomic push of main and a claimed branch whose commits include main's still matches the out-of-scope operation that was approved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
No conflicts. Full suite passes on the merged tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
#30 records each validated ref update's paths at pre-receive and replays them at the reference transaction's `prepared` stage. This PR adds a second list, scope_paths, measured against the mainline, so both are now recorded and replayed: migration 007 adds ref_updates.scope_paths, and evaluate_ref_update() computes whichever list it is not given. An update validated before 007 (NULL) recomputes its scope paths. test_prepared_uses_the_paths_pre_receive_derived pushes main and a claimed branch together; it fails if `prepared` recomputes scope_paths, because main landing first moves the mainline baseline. README keeps main's "hooks" wording and this PR's mainline-baseline note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Renumber 006_operation_policies.sql to 008, since 006 and 007 now hold the ref-update transaction tables. Check the required views by name before the schema fingerprint, as tables and triggers already are, so a dropped policy view is reported as such. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
Builds on #28, which is now merged. This carries forward the ref-transaction boundary from #10, rebuilt on #18's content-governance hook.
Problem
On
main, pre-receive uses up approvals and saves that to the store before Git moves the ref. Two things can then go wrong:receive.denyNonFastForwards), but the approval is already spent.Changes
ref_updates(migration006_ref_updates.sql), together with its changed paths and governed operations.preparedstage. A managedreference-transactionhook re-checks every recorded update while Git holds the ref locks and can still abort. Approvals are used up only here, and any failure aborts the update.committed/abortedstages record the outcome. An abort afterpreparedmakes the used-up approvals usable again, unless a newer live approval now exists for the same operation.HEAD, which Git lists alongside the ref it points to, is left to that ref's own line.hook installwrites both hooks, inspects both paths before writing either, and requires Git 2.28.doctorsettles updates stuck inpreparedby reading the ref itself. At the new value means committed; still at the old value means aborted, and--repairrestores the approvals. If the ref has moved elsewhere or is still locked, it's reported for manual inspection, never guessed.Notes for reviewers
reference-transactionhook. The local model still needs only 2.17.quorumgit hook install --repo <name>. Until then, pushes are rejected with a message saying the transaction hook is missing.pathsfor protected paths andscope_pathsfor claim scopes. Whichever of Measure claim scopes against the mainline, not every ref #29 and this PR merges second must record both lists in pre-receive and replay both atprepared. A worked example is the "Merge Measure claim scopes against the mainline, not every ref #29 …" commit onclaude/fervent-darwin-65ds9y.Tests
tests/test_transaction_boundary.pyhas 26 tests.out_of_scope_pushapproval keeps it when a later hook refuses the update, then uses it on a successful retry;preparedrecomputes the paths instead, this test fails and the push lands ungoverned.receive.denyNonFastForwardskeeps its approval. This test fails on currentmain, where the approval is used up and the branch doesn't move;preparedand restores both on abort;mainand a claimed branch still matches its approved out-of-scope operation.tests/test_hardening.pygains tests for the token-based schema comparison: formatting-only differences are accepted, a changed literal is still rejected.Validation
pytest tests -q: 225 passedpyright: 0 errorsruff check(the pinned 0.16.9): no findings🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp