Skip to content

feat(gr2): Prototype 2 contribution protocol — canonical destinations, lease-guarded up, sets, retire refusal, append surfaces - #897

Merged
laynepenney merged 3 commits into
devfrom
feat/gr2-propagation-prototype-2
Aug 20, 2026
Merged

feat(gr2): Prototype 2 contribution protocol — canonical destinations, lease-guarded up, sets, retire refusal, append surfaces#897
laynepenney merged 3 commits into
devfrom
feat/gr2-propagation-prototype-2

Conversation

@laynepenney

Copy link
Copy Markdown
Member

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 kind read_head reads refs/heads/<branch> (not HEAD), the cleanliness gate is recorded NOT RUN (not-run: a bare remote has no worktree; the gate is never silently omitted), and _verify skips the porcelain check. The postcondition is branch-is-intended-after-and-tree-matches-digest.
  • The up apply 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's destination.fast-forward gate 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 as observed_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 --force anywhere.
  • run(..., stop_after=State.PLANNED) drives an operation through its gates and stops BEFORE the apply verb, journaled at planned; a later run resumes 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 carries declared_by (the layer whose declaration put it in the workspace), overridden_by (the layer whose override governs it, if any), and write_mode (own / contribute / append / read); owner is overridden_by or declared_by. resolve(this_layer, declarations, appends=…) merges layer declarations keeping provenance; two layers declaring the same entry without overrides raises ResolutionCollision naming 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 to planned (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 reach acknowledged, leaving earlier members landed (git history is forward-only; a rollback would be a new forward operation, never an un-push) and writing a SetReceipt that names the landed, the refusing, and the not-attempted members. All-or-REPORT, not all-or-nothing.
  • Subspace — a workspace and its contributions. retire() raises RetireRefused listing every (coordinate, source revision, last state, operation id) that is neither acknowledged nor explicitly abandoned; abandon() writes an abandoned note 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 bare owner.git as the parent's canonical plus independent child clones) proves, each as its own witness:

  • a contribution walks observed → fetched → planned → applied → verified → acknowledged; the owner's branch reports the child's revision, read back from the owner and not from the verb; the receipt names exact revisions; the cleanliness gate is recorded not-run; verb_ran_now is true
  • the manufactured collision: after A lands, B's change authored against the OLD base is REFUSED at plan (destination.fast-forward fails) with observed_base = A's landing; the owner's refs and B's clone are byte-for-byte untouched
  • the compare-and-swap race: B plans while the owner is at the old base, the sink dies (kill_after=PLANNED), A lands, B resumes — REFUSED at apply because the expected base moved; nothing of B's reaches the owner
  • the lease closes the window the process-side check cannot see: with a test seam (before_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 land
  • replan is the AUTHOR's act: B rebases onto the owner's branch and re-contributes; the new revision is a fresh attempt and lands; the journal carries the refused attempt and the acknowledged one, both by name; the owner's tree carries both changes
  • a policy that does not allow up refuses at policy.direction before any verb

gr2/tests/test_contribution_protocol.py (new) proves, each as its own witness:

  • ownership is recorded provenance: declared_by / overridden_by / owner, the same declarations resolved AS the owning layer classify as own, append-only is declared by name; classify picks 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 control
  • a two-member set across two owners prepares every member (both owners still at their bases, transitions end at planned) 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_base named), not-attempted = (m3), m1 still landed, m3's owner untouched; empty and duplicate-id sets are refused
  • retire refuses while a prepared-but-unlanded contribution is open and names its operation id; landing it unblocks retire; a REFUSED contribution is open until an explicit abandoned note (counted in the journal), and abandoning a coordinate the subspace does not hold is a KeyError, not a no-op
  • two handles on one append file interleave in arrival order with sequence numbers 1, 2, 3 and the second handle reads the first handle's records; earlier records are a byte prefix of the file after every later append; four concurrent writers × 25 appends produce one gapless sequence with each writer's own order preserved
  • the per-state latency table is emitted (written to a file, printed under -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 the applied row spans prepare → land by construction, so it reads as the time the prepared base sat, not the push alone

Evidence (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 count
  • ruff check and ruff format --check clean on the four files touched
  • mutation run, thirteen rows, each row asserting its anchor applied (count == 1 AND the mutated file's hash differs from the saved copy — one row in the first pass had silently NOT applied, which is why the hash assertion was added) and the suite run to a verdict, the module restored from a saved copy and hash-verified after every row. Machine (5): a bare --force in 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 recorded pass instead of not-run killed 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 fixed
  • the whole gr2/tests tree at this head: 985 passed, 1 xfailed, 43 subtests passed, 5 failed; the same 5 (two in test_event_log_integrity.py, one each in test_gr2_packaging.py, test_sprint21_sync_platform.py, test_workspace_edit_lease_cap.py) fail identically on origin/dev at the base commit — pre-existing and untouched here, re-verified at the frozen head

Not in scope

  • the resolver change itself (declared_by on the real merged manifest) — this PR specifies it with a stub and witnesses; the change to the resolver is the next step
  • git-tracked append surfaces (a path inside the commit graph declared append would 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 so
  • across propagation, any transport, any policy content beyond the allowed-directions literal the tests hand the machine

Premium 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.

laynepenney and others added 3 commits August 20, 2026 05:37
…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>
@laynepenney

Copy link
Copy Markdown
Member Author

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.

@laynepenney

Copy link
Copy Markdown
Member Author

r2 — APPROVE (Apollo), bound to head 47937968e251946db33c9dd50869be1e5915af0f. One measured finding as a required follow-on, not a block. RAN, not READ.

Verified

All four frozen hashes re-derived exact: range 44ccab2c…ab78, metadata b92502b8…8a9c, title NORM a7d295fc…70bc, body NORM 01c230e3…8d0b. The live PR title and body NORM-hash to those same frozen values, so what is published is what was gated. Metadata carries 3 commits, equal to the ahead count and to the From lines in the range; author and committer identical on every commit.

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:

Row Result
bare --force in place of the lease killed exactly test_the_lease_refuses_a_move_inside_the_window_after_the_head_check
classify first-match instead of longest-prefix killed exactly test_w1_classify_picks_the_longest_declared_prefix_and_refuses_undeclared_paths

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: AppendSurface carries the torn-append defect, in a worse form

AppendSurface.append writes and fsyncs with no terminator repair, and reads the sequence number with a bare json.loads. Measured, not argued — a partial trailing record produces:

  1. append() raises JSONDecodeError. The surface is bricked. The seq read sits on the write path, so every future contribution to that surface fails, permanently, with no recovery but hand-editing. Journal's version of this defect sat on a read path and exited a restartable loop; this one refuses contributions forever.
  2. records() raises too — the read side is equally undefended.
  3. The tear eats the next record: {"seq": 3, "writer": "w1", "pay{"payload": {"n": 5}, …} — a record written correctly and fsynced, unreadable.

This is the same defect I measured and fixed in Journal this morning, in the sibling module, unswept. That is my miss as much as yours: I fixed the cited instance and did not sweep the class.

Why this is not a block. Everything here is prototype and synthetic — tmp_path, no real workspace touched, nothing on a production path. More decisively, the identical defect shipped in Journal through this same gate and became a follow-on issue rather than a block; holding AppendSurface to a stricter standard now would be inconsistent. But the severity is genuinely higher than the Journal case, so it should be prioritized rather than filed and forgotten. I have the fix already written and will take it.

Witness-set gap

Every append witness writes cleanly. Zero tear, truncate, partial, or kill fixtures anywhere in test_contribution_protocol.py, against 11 kill_after fixtures in the state-machine suite as the control proving the query is not blind. So the append witnesses test concurrency thoroughly — arrival order, gapless sequences, four writers × 25 appends — and durability not at all. They share a structural property, which is why the whole set passes over a defect any one of them would catch if it were interrupted.

Class sweep

Defining query: an append-mode write paired with a bare json.loads over file lines, control-verified by confirming it finds the Journal I already fixed.

  • Measured: propagation_state_machine.py (fixed on dev), contribution_protocol.py 493 / 501 / 517 (this PR).
  • Identified structurally, NOT individually measured: lane_workspace_prototype.py 415 / 436, recall_lane_history.py 45 / 57, and python_cli/events.py 192 with readers at 133 and 246.

That last one is in python_cli, not a prototype — which puts this class outside prototype scope and is the reason the follow-on is worth doing as a sweep rather than as one more point fix.

@laynepenney
laynepenney merged commit b74e086 into dev Aug 20, 2026
1 check passed
@laynepenney
laynepenney deleted the feat/gr2-propagation-prototype-2 branch August 20, 2026 11:12
laynepenney added a commit that referenced this pull request Aug 20, 2026
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>
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.

1 participant