Skip to content

Bind push authorization to Git's reference transaction - #30

Merged
Deep-Sixed merged 3 commits into
mainfrom
claude/fervent-darwin-65ds9y-ref-transaction
Sep 30, 2026
Merged

Deep-Sixed merged 3 commits into
mainfrom
claude/fervent-darwin-65ds9y-ref-transaction

Conversation

@Deep-Sixed

@Deep-Sixed Deep-Sixed commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

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:

  • Approval wasted: Git can still refuse the update after pre-receive (another hook, a lost ref lock, receive.denyNonFastForwards), but the approval is already spent.
  • Stale authority: a claim release, takeover or handoff that happens in that window lets a push land on authority that no longer holds.

Changes

  • pre-receive checks but spends nothing. docs: state the minimum Git version (2.17) #18's checks are unchanged. It now records each validated ref update in ref_updates (migration 006_ref_updates.sql), together with its changed paths and governed operations.
  • Transaction hook, prepared stage. A managed reference-transaction hook 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.
    • It re-checks using the paths pre-receive recorded. If one push updates two refs that share a new commit, the first ref's update makes that commit known before the second ref is checked. Recomputing the paths then would find nothing to govern.
  • committed / aborted stages record the outcome. An abort after prepared makes the used-up approvals usable again, unless a newer live approval now exists for the same operation.
  • Fail closed:
    • Only transactions without an agent identity (maintenance run inside the hub) pass through.
    • An identified transaction without a fresh validation from pre-receive is rejected, and so is one arriving while the store is missing.
    • HEAD, which Git lists alongside the ref it points to, is left to that ref's own line.
  • Hook installation. Pre-receive refuses pushes if the transaction hook is missing, modified or not executable. hook install writes both hooks, inspects both paths before writing either, and requires Git 2.28.
  • doctor settles updates stuck in prepared by reading the ref itself. At the new value means committed; still at the old value means aborted, and --repair restores the approvals. If the ref has moved elsewhere or is still locked, it's reported for manual inspection, never guessed.
  • Follow-up to Verify the full schema contract and harden commit and CLI inputs #28's review: the schema-contract check now compares SQL as a sequence of tokens. Keyword case, whitespace and comments no longer make an object look altered; quoted literals and identifiers still have to match exactly. This addresses Codex's P2 finding on Verify the full schema contract and harden commit and CLI inputs #28, which arrived after Verify the full schema contract and harden commit and CLI inputs #28 had merged.

Notes for reviewers

Tests

tests/test_transaction_boundary.py has 26 tests.

tests/test_hardening.py gains tests for the token-based schema comparison: formatting-only differences are accepted, a changed literal is still rejected.

Validation

  • pytest tests -q: 225 passed
  • pyright: 0 errors
  • ruff check (the pinned 0.16.9): no findings

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp

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
@Deep-Sixed
Deep-Sixed changed the base branch from claude/fervent-darwin-65ds9y-hardening to main September 29, 2026 23:42
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

Copy link
Copy Markdown
Owner Author

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 006, paths recorded in pre-receive and replayed at prepared, and several approvals per update. That work is redundant now, but it contains three regression cases this PR's tests/test_transaction_boundary.py doesn't seem to cover, plus a follow-up for #29.

All of it is at 44a6b5f on claude/fervent-darwin-65ds9y, in tests/test_transaction_boundary.py (bottom section). My merge commits sit on top of your 4edf457, which is unchanged.

Tests worth porting

  1. test_approval_survives_a_non_fast_forward_git_denies_by_config: the hub sets receive.denyNonFastForwards=true, which git init --shared also turns on. Git checks it after pre-receive, so an approved force-push is refused at that point. The approval must stay approved, and the retry must use it. On main today, the approval is consumed and the branch doesn't move.
  2. test_every_approval_of_one_update_is_consumed_and_restored_together: a push to protected main that also edits a protected path needs two approvals. Both must be consumed at prepared and both restored on aborted. In my version the approvals were linked in a ref_update_approvals table; with your approval_ids column, the assertion that counts linked rows would check that array instead.
  3. test_prepared_uses_the_paths_pre_receive_derived: a non-atomic push of main (approved) and a claimed work/x whose commits include main's new commit. Git commits main first, so recomputing paths at work/x's prepared stage shrinks its out-of-scope set. The approved hash then no longer matches and Git aborts a fully approved push. I confirmed the test fails when prepared recomputes paths. Your test_prepared_checks_the_paths_pre_receive_derived may already cover this; if it does, ignore this one.

Follow-up once #29 merges
#29 splits the changed paths in two: paths, measured against every ref, for protected paths; and scope_paths, measured against the branch's previous value plus the default branch and protected refs, for claim scopes. It adds a scope_paths argument to governed_operations. This PR's pre-receive would need to record scope_paths too, and prepared would need to replay both lists. The merge commit "Merge #29 …" in the same range shows one way to do it.


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
@Deep-Sixed
Deep-Sixed marked this pull request as ready for review September 30, 2026 01:17
@Deep-Sixed
Deep-Sixed merged commit f0e3018 into main Sep 30, 2026
7 checks passed
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
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
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
#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
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
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
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.

2 participants