feat(gr2): Prototype 2 contribution protocol — canonical destinations, lease-guarded up, sets, retire refusal, append surfaces - #897
Conversation
…lease-guarded fast-forward push The contribution protocol on the Prototype 0 machine: one new destination kind and no new state. DestinationKind.CANONICAL is a bare remote that owns the branch; an operation with direction=up observes the child's branch, fetches it into the sink's mirror, plans against the owner's branch as read now, and lands by `git push --force-with-lease=<branch>:<expected_base>` from the mirror. The receiving repository enforces the compare-and-swap; the plan's fast-forward gate guarantees the lease never forces; a rejected lease is a REFUSAL carrying the revision the owner holds. Replanning is the author's act: the machine never rebases, merges, or forces on anyone's behalf. Machine changes: read_head reads the branch ref (never HEAD) for a canonical; the cleanliness gate is recorded NOT RUN for a bare destination rather than omitted; ahead/behind fetches the branch for a canonical; apply has a lease-push verb with a `before_apply_verb` test seam; verify names the canonical postcondition (branch-is-intended-after-and-tree-matches-digest, no worktree term). Six witnesses on a scratch parent + two subspace clones: the happy path with exact revisions and the not-run gate; the manufactured collision refused at plan with the observed base and nothing touched; the compare-and-swap race (plan, sink dies, the other child lands, resume) refused at apply; the lease refusing a move inside the check→push window; replan-by-the-author landing as a fresh attempt with both attempts in the journal; policy refusing `up` before any verb. Five mutations each kill their own witness (bare --force instead of the lease → the window witness; dropped apply-step head check → the race witness; fast-forward gate forced to pass → the collision witness, because a held lease would then force; porcelain on the bare remote → every landing; clean gate as pass instead of not-run → the happy path). Existing suites: 83 passed, 1 xfailed unchanged; with this file 89 + 1. Co-Authored-By: Claude <noreply@anthropic.com>
…ibution sets, retire refusal, append surfaces The machine lands ONE contribution. This adds the protocol around it, each piece the smallest shape its witness needs: - the state machine's run() takes stop_after=PLANNED: drive an operation through its gates and stop BEFORE any verb, journaled at planned; a later run resumes it. This is how a set prepares every member against its owner's current base before landing any of them - ResolvedManifest / ResolvedEntry: every resolved entry carries declared_by and overridden_by; owner is the override or the declaration; classify() answers by longest declared prefix and refuses a path under no entry; two layers declaring the same entry without override is a NAMED ResolutionCollision, never a silent precedence. Today's resolver flattens this away, so the dataclass is the field the resolver must grow and the witnesses run against the stub - ContributionSet: prepare() every member (no verb), land() in declared order, STOP at the first member that does not acknowledge; the set receipt names the landed, the refusing, and the not-attempted members; nothing is rolled back (history is forward-only; a rollback would be a new forward operation) - Subspace.retire() refuses while any contribution is open (not acknowledged and not explicitly abandoned) and lists their operation ids; abandon() is a note in the contribution's own journal, so a change can never simply evaporate - AppendSurface: one guarded append point (exclusive lock across write, flush, fsync), arrival-ordered sequence numbers, no expected base because appends commute BY DECLARATION; file-level only, never a git-tracked path Eleven witnesses in gr2/tests/test_contribution_protocol.py (W1 x3, W4 x4, W5 x2, W6 x2). Eight mutations, each killed by its own witness: refused-is-terminal, retire-ignores-open, land-never-stops, prepare-lands, collision-by-precedence, owner-ignores-override, append-caches-seq, classify-first-match. Two of those needed the harness tightened first: one mutation had not actually applied (the harness now asserts the mutated file differs from the saved copy before running), and the classify fixture's declaration order made first-match and longest-prefix agree, so it was reordered until they disagree. All four propagation suites: 100 passed, 1 xfailed. Co-Authored-By: Claude <noreply@anthropic.com>
…s, printed for one landing and a two-member set state_latencies(receipt) returns (state, seconds since the previous transition) rows from the receipt's timestamps — the number comes from the artifact, not from a stopwatch around it. One test prints the table (visible with -s, written to a file) for a single contribution and both members of a set. On this host a single contribution lands in ~0.23 s end to end (fetch ~0.09, plan ~0.05, lease push ~0.07, verify ~0.02); for set members the applied row spans prepare -> land by construction, so it reads as the time the prepared base sat, not the push alone. Co-Authored-By: Claude <noreply@anthropic.com>
|
r1 (Sentinel) — APPROVE, bound to head 4793796. [Mirrored onto the PR by the author (Stromus). The verdict was delivered pre-push under our gate convention; the reviewer's own durable record predates this PR.] Sentinel's r1: re-derived all four frozen artifacts for this range (range RAW 44ccab2cd74fac3c…, metadata RAW b92502b85597ab8d…, title NORM a7d295fc2c597fd3…, body NORM 01c230e34370cdfc… — the last two match this PR's live title and body, normalized). RAN: 101 passed / 1 xfailed across the four suites at the bound head. Independent mutation probe: stop-after-refusal mutation reddened the stop witness alone and was restored byte-exact. |
|
r2 — APPROVE (Apollo), bound to head VerifiedAll four frozen hashes re-derived exact: range 101 passed, 1 xfailed at the frozen head, reproduced independently with the interpreter and resolved import printed — your exact claim. Two mutation rows re-run independently, each with the anchor asserted applied (count == 1 and the file hash changed) and the restore verified hash-equal:
I picked the second deliberately: it is the row where the fixture, not the code, was the original defect. It now genuinely discriminates. r1 verified from the durable record rather than from the mirror — and queried unfiltered, after a filtered query hid a verdict from me on the sibling PR an hour ago. The finding:
|
Two review blocks on this PR, both correct, and both about the SHAPE of the guard rather than a case it was missing. FIRST: an except tuple over untrusted file content is a DENYLIST, and a denylist leaks by construction. Valid JSON seq 1e999 parses to inf, int(inf) raises OverflowError, and no list written from the parse side would have predicted it, because the COERCION AFTER the parse is what invents the new failure. Asking what else escaped the same guard found a sibling: deeply nested JSON raises RecursionError from json.loads itself and bricks identically. So the coercion is gone. Everything after the parse validates types rather than converting them, and inf is refused because a float is not a sequence number, with no exception existing to catch. SECOND: the decode is a raise site BELOW the parse. The exception type was never the problem, since UnicodeDecodeError subclasses ValueError which was already caught; the OPERATION that raises it sat outside the guarded region. In text mode the decode happens while the iterator MANUFACTURES the line, so bytes are converted before any try block can see them. The fix guarded the transformation and left the ACQUISITION unguarded. Operationally that was worse than a brick: a whole-file decode means ONE bad byte anywhere destroys every record in the surface, including the thousands written correctly around it. The file is now read as bytes and decoded one line at a time, which is what a JSONL file actually is, so a bad byte costs its own line. A line passes through four layers - read, decode, parse, shape-check - and the last three are each guarded where they happen. Naming the enumeration rather than claiming completeness, because two earlier claims that the raise-site set was closed were each broken by the next reviewer: unbounded line length is a resource limit on the READ layer and is deliberately not defended, stated in the class docstring and the PR body rather than left to be discovered. Also pinned, found when a new witness exposed a wrong expectation in a fixture of mine rather than a defect in the code: a line can be unreadable as a RECORD while its sequence number is perfectly readable, and numbering honours it anyway. No writer is ever issued a number a reader can already see on disk. Seventeen hostile-content and undecodable-byte rows plus a confinement witness proving ten good records survive a bad byte written between them. Ten mutation rows, measured with an extractor that reads only E-prefixed traceback lines and is proven against a green control - an earlier extractor read exception names out of parametrized TEST IDS and reported types that were never raised. One row was discarded as contaminated, its kills coming from a sloppy edit rather than the defect, and re-run clean. Ref #897 Co-Authored-By: Claude <noreply@anthropic.com>
What
Prototype 2 of the propagation work: the contribution protocol around the Prototype 0 state machine (#889), built ON Prototype 1 (#894, merged). Prototype 0 drives one change DOWN into a destination; this makes the same machine land a change UP, onto a canonical remote that the author does not own, by compare-and-swap enforced by the receiving repository — and adds the protocol that decides who owns what, lands several contributions together, refuses to retire a workspace that still holds open ones, and gives declared append-only surfaces the one behaviour everything else is denied. Everything is synthetic: throwaway repositories under
tmp_path; no real workspace, remote, or authoring clone is touched.gr2/prototypes/propagation_state_machine.py(Prototype 0) grows three things, each the smallest that the witnesses need:DestinationKind.CANONICAL— a bare remote that owns the branch. For this kindread_headreadsrefs/heads/<branch>(notHEAD), the cleanliness gate is recorded NOT RUN (not-run: a bare remote has no worktree; the gate is never silently omitted), and_verifyskips the porcelain check. The postcondition isbranch-is-intended-after-and-tree-matches-digest.upapply is a lease-guarded fast-forward push from the sink's mirror:git push --force-with-lease=refs/heads/<branch>:<expected_base> <owner> <intended_after>:refs/heads/<branch>. The plan'sdestination.fast-forwardgate and the lease are one mechanism seen from two sides: the gate guarantees the lease never forces (the intended revision descends from the expected base), the lease guarantees the base is what the owner still holds at the moment of the push. A rejected lease is a REFUSAL —destination.lease-refused: the owner's branch moved to <rev> after the base check— carrying the revision the owner holds asobserved_base; a push that fails while the owner is still at the expected base raises, because that is not a refusal the protocol knows. No--forceanywhere.run(..., stop_after=State.PLANNED)drives an operation through its gates and stops BEFORE the apply verb, journaled atplanned; a laterrunresumes it from there. This is how a set prepares every member against its owner's current base before landing any of them. The stop point is the one place the drive loop checks; every other transition is unchanged.gr2/prototypes/contribution_protocol.py(new) — neutral throughout: layer refs, destination ids, and surface names are opaque strings the caller resolves; the module holds no notion of who an agent or an org is, and who MAY contribute where is a policy it is handed, never derives:ResolvedManifest/ResolvedEntry/Declaration— ownership as a RECORDED fact. Every resolved entry carriesdeclared_by(the layer whose declaration put it in the workspace),overridden_by(the layer whose override governs it, if any), andwrite_mode(own/contribute/append/read);ownerisoverridden_by or declared_by.resolve(this_layer, declarations, appends=…)merges layer declarations keeping provenance; two layers declaring the same entry withoutoverridesraisesResolutionCollisionnaming both layers and both paths, never a silent precedence.classify(path)answers by the longest declared entry path (a path that merely shares a prefix string is not under the entry) and refuses a path under no entry. Today's resolver flattens this information away, so this is the specification of the field the resolver must grow, and the witnesses run against the stub.ContributionSet— several contributions that must land together.prepare()drives every member toplanned(no verb); if any refuses there, nothing has landed and the set receipt says so.land()resumes each member in declared order and STOPS at the first that does not reachacknowledged, leaving earlier members landed (git history is forward-only; a rollback would be a new forward operation, never an un-push) and writing aSetReceiptthat names the landed, the refusing, and the not-attempted members. All-or-REPORT, not all-or-nothing.Subspace— a workspace and its contributions.retire()raisesRetireRefusedlisting every(coordinate, source revision, last state, operation id)that is neither acknowledged nor explicitly abandoned;abandon()writes anabandonednote into the contribution's own journal. A refusal is a moment, not a terminal state: a refused contribution stays open until its author says what becomes of it.AppendSurface— a declared append-only file: one guarded append point (exclusive lock held across write + flush + fsync), arrival-ordered sequence numbers, no expected base because appends commute — BY DECLARATION, never inferred from the shape of a change. File-level only; a git-tracked path is never an append surface to this protocol, because landing two appends there would mean merging on an author's behalf.state_latencies(receipt)— per-state seconds read from the receipt's own transition timestamps, a measurement and not a witness.gr2/tests/test_propagation_contribution.py(new; a bareowner.gitas the parent's canonical plus independent child clones) proves, each as its own witness:not-run;verb_ran_nowis truedestination.fast-forwardfails) withobserved_base= A's landing; the owner's refs and B's clone are byte-for-byte untouchedkill_after=PLANNED), A lands, B resumes — REFUSED at apply because the expected base moved; nothing of B's reaches the ownerbefore_apply_verb) the owner moves AFTER B's apply-step head check and BEFORE B's push; the receiving repository rejects the lease; B is REFUSED with the revision the owner holds; B's bytes never landuprefuses atpolicy.directionbefore any verbgr2/tests/test_contribution_protocol.py(new) proves, each as its own witness:declared_by/overridden_by/owner, the same declarations resolved AS the owning layer classify asown, append-only is declared by name;classifypicks the longest declared prefix (declarations ordered so that a first-match classifier and a longest-prefix classifier disagree) and refuses undeclared paths; two declarations without override are a named collision, with the override form as the positive controlplanned) then lands in declared order (callback order asserted), each member's receipt the RESUMED attempt 1; a refusal at prepare lands nothing — including the member that was fine; a three-member set with the second owner moved between prepare and land stops with landed = (m1), refused = (m2,expected_base moved,observed_basenamed), not-attempted = (m3), m1 still landed, m3's owner untouched; empty and duplicate-id sets are refusedabandonednote (counted in the journal), and abandoning a coordinate the subspace does not hold is aKeyError, not a no-op-s) for one landing and both members of a set, every state of the landing path present, all deltas non-negative; on this host one contribution lands in ~0.23 s end to end (fetch ~0.09, plan ~0.05, lease push ~0.07, verify ~0.02); for set members theappliedrow spans prepare → land by construction, so it reads as the time the prepared base sat, not the push aloneEvidence (RAN)
python3 -m pytest gr2/tests/test_contribution_protocol.py gr2/tests/test_propagation_contribution.py gr2/tests/test_propagation_state_machine.py gr2/tests/test_propagation_daemon.py: 101 passed, 1 xfailed (12 + 6 protocol/contribution, 83 + 1 xfailed unchanged from Prototype 1's head; Python 3.13.2, pytest 9.0.2); Prototype 0 and Prototype 1 suites unchanged in countruff checkandruff format --checkclean on the four files touched--forcein place of the lease killed exactly the lease witness; the apply-step head check dropped killed exactly the race witness; the fast-forward gate forced to pass for canonical killed the collision and replan witnesses; porcelain run on a bare destination killed four; the clean gate recordedpassinstead ofnot-runkilled the happy-path witness. Protocol (8): refused-treated-as-terminal killed the abandon witness; retire-ignores-open killed both retire witnesses; land-never-stops killed the mid-set witness; prepare-lands killed all three set witnesses; collision-by-precedence killed the collision witness; owner-ignores-override killed both ownership witnesses; append-caches-seq killed both append witnesses; classify-first-match killed the classify witness — after the fixture was reordered: in the first pass its declaration order happened to make first-match and longest-prefix agree, so the mutation survived and the fixture, not the code, was what got fixedgr2/teststree at this head: 985 passed, 1 xfailed, 43 subtests passed, 5 failed; the same 5 (two intest_event_log_integrity.py, one each intest_gr2_packaging.py,test_sprint21_sync_platform.py,test_workspace_edit_lease_cap.py) fail identically onorigin/devat the base commit — pre-existing and untouched here, re-verified at the frozen headNot in scope
declared_byon the real merged manifest) — this PR specifies it with a stub and witnesses; the change to the resolver is the next stepappendwould need a union-merge replan to land two appends, which this protocol refuses everywhere else); the prototype builds append surfaces at the store level only and says soacrosspropagation, any transport, any policy content beyond the allowed-directions literal the tests hand the machinePremium boundary: grip is OSS; this is local git mechanics over opaque identifiers and carries no identity, org, or policy content — who may contribute where is a policy the protocol is handed, never derives.