git-ents.gitmain
⌘K
foforge
commit 4610740
spec: bind every meta-ref name to its signed content, retiring Advance-ref

Identity stops being minted and starts being derived. A meta-ref’s name is now a total function of what the ref holds, recomputed by the gate from the signed commit rather than trusted from a self-declared trailer. This closes a doppelganger replay (a signed mutation commit re-proposed as a fresh genesis) and a result-forgery replay (a signed pass proposed for a different effect or commit), neither of which a trailer could prevent.

spec: replace meta-ref.trailers with meta-ref.identity-binding spec: replace gate.refname-binding with gate.identity-binding spec: add gate.owner-mutation for genesis-oid-keyed advance authority spec: add model.result-identity binding results to effect + target spec: key model.review by composite reviews/<target>/<member> spec: derive model.comment and model.issue ids from the genesis oid spec: give Member and Effect natural-key name fields spec: add phase 10 to the development plan deprecates: Advance-ref and the never-used Schema-Version trailer Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

docs/abstractions.adoc @@ -38,9 +38,8 @@ History keeps the old encoding as archive. Trees stay pure struct representations — no version marker entry. -Ref-level metadata that is not entity content belongs in commit-message trailers, because the commit is already the storage unit. -Two trailers are reserved: `Schema-Version:` for explicit encoding detection if it is ever needed, and `Advance-ref:` for refname binding (see 4). -Versioning or binding in the tree would pollute both the schema and the merge path. +Ref-level metadata that is not entity content — who, when, the signature — lives in the commit header, because the commit is already the storage unit; no reserved trailer exists. +Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification (see 4). *Tip invariant:* the tip of a meta-ref is always readable by the binary that owns the entity type; a non-owning binary treats the tree as opaque and degrades to generic display (`git store show`); history is archival. (Redacted entries are the one qualification: readers surface a withheld object as a redaction marker, never an error — see Derived.) @@ -67,7 +66,7 @@ A commit signature proves authorship; it does not prove placement. The difference is recovered explicitly: -* *Refname binding* — an `Advance-ref:` trailer names the ref the commit was authored for, checked at verification; without it, a signed commit could be replayed as the tip of a different meta-ref. +* *Refname binding* — the refname is recomputed from the commit's own signed content (a genesis oid, a natural-key tree field, a composite of fields and signer) and a mismatch refuses; without this, a signed commit could be replayed as the tip of a different meta-ref. * *Anti-replay* — meta-refs advance fast-forward-only in the DAG sense (the new tip descends from the old), enforced by atomic CAS on the ref store; the parent hash is the freshness binding, so no nonce is needed. *Tip invariant:* the tip of a meta-ref is signed by a member authorized for that refname. @@ -91,7 +90,7 @@ [arabic] . The new tip is signed by a member authorized for this refname. -. The `Advance-ref:` trailer matches the refname. +. The refname recomputes from the tip's signed content. . The new tip descends from the old tip. . The update commits via atomic CAS.
docs/design.adoc @@ -27,7 +27,7 @@ Mutations are author-signed commits, not push certificates. A push cert signs a transition and evaporates at the transport layer; a commit signature replicates with the repo and verifies in every clone, forever. Verification evidence is repository state, like everything else. -Refname binding lives in an `Advance-ref:` trailer; anti-replay is fast-forward-plus-CAS, with the parent hash as the freshness binding. +Refname binding is recomputed from signed content — every meta-ref's name is a total function of what it holds (a genesis commit's oid, a natural-key tree field, a composite of fields and signer); anti-replay is fast-forward-plus-CAS, with the parent hash as the freshness binding. Because writing and verifying are separated, local is genuinely offline-first: the local store accepts any write and the gate merely annotates. You can author while unenrolled, work against an unfetched member list, and accumulate meta-refs the canonical store would reject.
docs/development-plan.adoc @@ -225,7 +225,7 @@ back-compat reader), working-tree capture and projection in `ents-anchor` (`anchor.working-tree`), the Review entity plus its retention pin ref (`model.review-pin` — - `refs/meta/pins/reviews/<id>`, an empty-tree signed commit whose + `refs/meta/pins/reviews/<target>/<member>`, an empty-tree signed commit whose parents include the reviewed commit; verify the gate's tip-signed and fast-forward checks accept the merge-shaped pin advance), and porcelain — `comment reply/resolve/reopen`, @@ -261,6 +261,35 @@ commit pages (`model.review`), reply/resolve on the existing comment views. Reuses the registry-driven meta rail and session/CSRF machinery already in place. + +| kernel identity binding (`ents-model`, `ents-gate`, `ents-receive`, + `ents-sync`, `ents-effect`, `ents-testutil`) +| 10 +| High +| fable +| One atomic migration — the kernel cannot build half-converted. Retire + `trailer.rs` (`Advance-ref` and the never-used `Schema-Version`) in + favor of `meta-ref.identity-binding`: the gate recomputes each refname + from signed content — the parentless-roots walk for hash-identified + namespaces (never applied to pins, whose ancestry reaches code + history), natural-key tree fields (`Member` and `Effect` gain their + name field), composite review segments, and result trees gaining + effect + target (`model.result-identity`, closing a result-forgery + replay). Strict genesis decode plus the pairwise schema-disjointness + test; `gate.owner-mutation`; redaction vouching; `propose_*` grows the + sign-then-name genesis flow (the ref named from the signed commit's + own oid — no circularity, since no commit names its own ref anymore). + +| forge and surface identity migration (`ents-forge`, `git-ents`, + `ents-web`, `ents-lens`, skills) +| 10 +| Medium +| sonnet +| `uuid` leaves the workspace: comment and issue ids are genesis commit + oids, reviews move to the composite `reviews/<target>/<member>` key + with re-review's fast-forward advance now a reachable CLI path; + porcelain and web abbreviate ids the way git abbreviates oids; lens + and agent skills updated for the id format. |=== == Phase gates @@ -313,6 +342,18 @@ back-compat reader is kept — `meta-ref.migration`'s forward rewrite applies whenever data actually needs carrying, not a permanent legacy code path. +* *Phase 10 exit*: identity is derivation — no minted id and no binding + trailer exists anywhere in the workspace. The doppelgänger replay (a + signed mutation commit proposed as the genesis of a new entity) and + the result replay (a signed `pass` proposed for a different effect or + commit) are refused by tests driving `receive` + (`gate.identity-binding`, `model.result-identity`); entity structs + prove pairwise disjoint under strict decode; a comment created, + replied to, and resolved, and a commit reviewed then re-reviewed + (the pin advancing fast-forward), round-trip through CLI and web + under genesis-oid and composite ids (`model.comment`, + `model.review`). Same clean-break rule as phase 9: no legacy reader, + ids regenerate with the data. Backend conformance tests are shared suites written once against each trait (`RefStore`, `EventSink`, `Executor`) and run per implementation —
docs/faq.adoc @@ -150,7 +150,7 @@ Revocation is a state, never a deletion — deleting the entity would merely make their old signatures unverifiable, which is the opposite of what an audit needs. *I want to verify that nobody tampered with forge state.* -Every meta-ref tip must be signed by a member authorized for that refname, bound to the ref by an `Advance-ref:` trailer, descending from the previous tip. +Every meta-ref tip must be signed by a member authorized for that refname, bound to the ref by recomputing the refname from the tip's own signed content, descending from the previous tip. `git ents members check` verifies this from any clone, with no server's cooperation. *I want to know who did what, and when.*
docs/spec/gate.adoc @@ -22,12 +22,41 @@ a gate path. -- -[role="requirement", id="gate.refname-binding"] -.Refname Binding +[role="requirement", id="gate.identity-binding"] +.Identity Binding -- -The commit's `Advance-ref:` trailer MUST match the refname being updated. +The refname being updated MUST be recomputable from the proposed tip's +signed content, per namespace exactly as <<meta-ref.identity-binding>> +tabulates; a mismatch MUST refuse. Without this check a signed commit could be replayed as the tip of a -different meta-ref than the one its author signed for. +different meta-ref than the one its author created it for — including +a signed pass replayed as the result of a different effect or commit, +which is why a result's effect and target are tree fields +(<<model.result-identity>>). +The creation of a hash-identified entity MUST strictly decode as its +namespace's entity type, an unknown tree entry refusing, so one signed +genesis can never be admitted under two namespaces; entity structs +MUST stay pairwise disjoint under this decode, a property held by test +rather than by a stored marker (<<meta-ref.typed-tree>>). +When an object the binding needs is withheld by redaction, the binding +MUST be vouched by the admin-signed redaction record instead of +recomputed, and a redacted object MUST NOT be re-admitted +(<<receive.redaction-ingest>>, <<model.redaction>>). +-- + +[role="requirement", id="gate.owner-mutation"] +.Ownership Keys Mutation +-- +Advancing a hash-identified entity's ref MUST be authorized only for +the member whose signature its genesis carries — ownership intrinsic +to the id, which is the genesis commit's oid — or for an +admin-registered member; a review ref MUST advance only under the +signature of the member its refname names (<<model.review>>). +Creation stays provenance-keyed exactly as <<model.member-provenance>> +routes it: a self-attested member creates and mutates through its own +inbox, adopted onto the canonical ref by merge +(<<gate.adoption-merge>>), which satisfies the parentless-roots walk +because the contributor's genesis remains the history's sole root. -- [role="requirement", id="gate.fast-forward"]
docs/spec/lens.adoc @@ -54,7 +54,7 @@ -- Hovering a projected comment's range MUST return the full thread — bodies, states, and authorship read from each ref's mutation commit -chain (<<meta-ref.trailers>>) — rendered as markup, so the complete +chain (<<meta-ref.identity-binding>>) — rendered as markup, so the complete conversation is readable without leaving the buffer. --
docs/spec/meta-ref.adoc @@ -98,14 +98,59 @@ a version-marker entry. -- -[role="requirement", id="meta-ref.trailers"] -.Reserved Commit Trailers +[role="requirement", id="meta-ref.identity-binding"] +.The Refname Is a Function of Signed Content -- -Ref-level metadata that is not entity content MUST live in the mutation -commit's trailers, never inside the tree (<<meta-ref.typed-tree>>). -Two trailers are reserved: `Schema-Version:`, for explicit encoding -detection if it is ever needed, and `Advance-ref:`, for binding the commit -to the refname it was authored for. +A meta-ref's name MUST be a total function of its signed content, and +the gate MUST recompute that function and refuse a mismatch +(<<gate.identity-binding>>); no commit-message trailer or other +side channel participates in the binding, and no reserved trailer +exists. +The function, by namespace: + +* Singleton state binds by its fixed name: `refs/meta/config`, + `refs/meta/account`. +* A natural-key entity binds by a designated tree field equal to the + refname's final segment: a member's id, an effect's name, a + toolchain's name (<<model.member-identity>>, + <<model.effect-definition>>, <<model.toolchain>>). +* A hash-identified entity binds by genesis: the refname's final + segment MUST equal the oid of the entity's genesis commit, and every + parentless commit reachable from the proposed tip MUST be that + genesis — comments and issues (<<model.comment>>, <<model.issue>>). + The reachability form, not a creation-time-only check, is what makes + replaying a signed mutation commit as the genesis of a doppelgänger + entity impossible, and it holds across the merge commits divergence + resolution creates (<<gate.same-actor-divergence>>). +* A composite-keyed entity binds by genesis fields and signer: a + review's `reviews/<target>/<member>` segments MUST equal its genesis + tree's target field and its genesis signer's member id + (<<model.review>>); a result's `results/<effect>/<short-oid>` + segments MUST derive from its own tree's effect and target fields + (<<model.result-identity>>), and a self-run's member segment MUST + additionally equal its signer (<<meta-ref.inbox>>). +* A retention pin binds by mirroring its entity's segments, with + parents that include the retained commit (<<model.review-pin>>). + A pin's ancestry deliberately reaches into code history, so the + parentless-roots walk above MUST NOT be applied to pins. +* An inbox ref binds by its owner segment equal to the signer + (<<meta-ref.inbox>>), with the canonical suffix bound exactly as its + canonical namespace binds. + +Who authored a state, and when, MUST come from the commit itself — its +header and its signature — never from duplicated tree fields: each +datum has exactly one signed home, and a tree field participates in the +binding only where the namespace's function above names it. +Within a meta-ref's history, a commit parent means exactly one thing — +the prior state of the same entity — with the pin's retained-commit +parents as the sole exception (<<meta-ref.namespace>>); a cross-entity +relationship MUST be tree data (a reply's parent field, a comment's +context field), never a parent edge. +A genesis commit is frozen by the identity derived from it, so the +struct of a hash-identified or composite-keyed entity MUST evolve +additively only — new fields optional, required fields never added, +renamed, or removed; <<meta-ref.migration>> governs the remaining +namespaces. -- === Tip Invariant and Migration
docs/spec/model.adoc @@ -31,7 +31,9 @@ [role="requirement", id="model.member-identity"] .Member Identity and Enrollment -- -A Member entity MUST carry the member's public key. +A Member entity MUST carry the member's public key, and its member id — +the final segment of its refname, which binds to this field +(<<meta-ref.identity-binding>>). Enrollment MUST occur as a signed commit written to the member's ref: the member becomes forge state the moment that commit lands, with no user database separate from the repository. @@ -94,11 +96,12 @@ or any combination — a comment about nothing MUST be refused at creation by the writing tool, though never by the gate, which stays content-agnostic (<<model.extensibility>>). -A comment's identity MUST be derived from a hash of its genesis content -and MUST NEVER change afterward — edits advance the ref, they do not -rename it. +A comment's identity MUST be the oid of its genesis commit — git's own +hash over the genesis tree, author, timestamp, and signature — and MUST +NEVER change afterward: edits advance the ref, they do not rename it, +and the refname binds to this genesis (<<meta-ref.identity-binding>>). Author and timestamp MUST come from the mutation commit chain, never from -fields stored in the tree (<<meta-ref.trailers>>). +fields stored in the tree (<<meta-ref.identity-binding>>). Anchor resolution, projection onto other revisions, and reachability are specified in `anchor.adoc` and apply to a Comment's anchor unchanged. -- @@ -114,7 +117,7 @@ Custom states beyond `open` and `resolved` are schema, not platform features, exactly as for issues (<<model.issue>>). Who changed a state, and when, MUST come from the mutation commit chain -(<<meta-ref.trailers>>), never from stored fields. +(<<meta-ref.identity-binding>>), never from stored fields. -- [role="requirement", id="model.comment-context"] @@ -149,6 +152,11 @@ An Issue entity MUST be a typed tree under its own ref in `refs/meta/issues/*`, one ref per issue (<<meta-ref.granularity>>), written, gated, synced, and audited exactly like a comment. +An issue's identity MUST be the oid of its genesis commit, exactly as a +comment's (<<model.comment>>, <<meta-ref.identity-binding>>) — no +sequential counter exists, because a counter is a coordination point +and issues are created offline and concurrently; porcelain abbreviates +ids the way git abbreviates commit oids. An Issue entity MUST carry a title, a body, a state, assignees, and labels as struct fields; multiple assignees and custom states are schema, not platform features. @@ -161,15 +169,21 @@ [role="requirement", id="model.review"] .Review Is a Verdict Plus a Context -- -A Review entity MUST be a typed tree under its own ref in -`refs/meta/reviews/<id>`, one ref per review (<<meta-ref.granularity>>), -written, gated, synced, and audited exactly like a comment. +A Review entity MUST be a typed tree under its own ref at +`refs/meta/reviews/<target>/<member>`, where `<target>` is the oid of +the first commit the review judged and `<member>` is the reviewer's +member id — a composite natural key (<<meta-ref.identity-binding>>): +one review thread per (target, reviewer), all reviews of a commit +enumerable by ref prefix, no minted id anywhere. Every review MUST occupy exactly two refs: the entity ref above, and a -retention pin at `refs/meta/pins/reviews/<id>` anchoring the reviewed -content itself (<<model.review-pin>>). -A Review MUST carry the id of the most recently reviewed commit as a -plain data field, a verdict, and a body; reading the field MUST NOT -require the pin ref — the pin anchors, the entity describes. +retention pin at `refs/meta/pins/reviews/<target>/<member>` anchoring +the reviewed content itself (<<model.review-pin>>). +A Review MUST carry the oid of the most recently reviewed commit as a +plain data field — at genesis this equals the refname's `<target>` +segment and binds it; re-reviewing a descendant advances the field +while the refname stays keyed by genesis — plus a verdict and a body; +reading the field MUST NOT require the pin ref: the pin anchors, the +entity describes. `approve` and `request-changes` are conventions, not an enum, because custom verdicts are schema, not platform features (<<model.extensibility>>). @@ -178,14 +192,17 @@ code where they concern specific lines (<<anchor.definition>>); the review itself MUST NOT store a list of its comments. Reviewer and timestamp MUST come from the mutation commit chain -(<<meta-ref.trailers>>). +(<<meta-ref.identity-binding>>); the reviewer needs no tree field, +being both a refname segment and the signer the gate checks +(<<gate.owner-mutation>>). -- [role="requirement", id="model.review-pin"] .The Review Pin Anchors the Reviewed Content -- -A review's pin ref, `refs/meta/pins/reviews/<id>` — the entity's own -canonical suffix prefixed the same way <<meta-ref.inbox>> prefixes one — +A review's pin ref, `refs/meta/pins/reviews/<target>/<member>` — the +entity's own canonical suffix prefixed the same way <<meta-ref.inbox>> +prefixes one — MUST keep the reviewed content reachable: its tip is a signed commit authored by the reviewer whose parents include the reviewed commit, so that commit and its ancestry survive force-push, branch deletion, and gc @@ -207,11 +224,12 @@ [role="requirement", id="model.effect-definition"] .Effect Definition -- -An Effect entity MUST carry: a trigger, a `CommitQuery` denoting the set -of commits the effect fires for; the toolchains its run requires; and a -run command. +An Effect entity MUST carry: its own name, the final segment of its +refname, which binds to this field (<<meta-ref.identity-binding>>); a +trigger, a `CommitQuery` denoting the set of commits the effect fires +for; the toolchains its run requires; and a run command. Its results refname is derived from the effect's own name -(<<effect.results-writeback>>), never stored as a field. +(<<effect.results-writeback>>), never stored as a separate field. It MUST NOT carry executor, sandbox, or retry fields; how an effect runs is a deployment property (<<effect.deployment-property>>). -- @@ -226,6 +244,23 @@ run semantics, specified in <<effect.result-taxonomy>>. -- +[role="requirement", id="model.result-identity"] +.A Result Names What It Judged +-- +A Result entity MUST carry the effect's name and the full oid of the +commit the run judged as tree fields, from which its refname's +`<effect>` and `<short-oid>` segments derive +(<<meta-ref.identity-binding>>). +Without these fields the refname is the only thing tying a signed +status to a run, and a signed `pass` could be replayed as the result +of any effect on any commit; a result MUST mean something with the +refname stripped away. +The fields are not parent edges: a result ref's parents stay prior +states of the same result (<<meta-ref.identity-binding>>'s +one-meaning-per-edge rule), and a result MUST NOT retain the judged +commit's ancestry the way a pin does (<<model.review-pin>>). +-- + === Toolchain [role="requirement", id="model.toolchain"]
docs/spec/overview.adoc @@ -67,17 +67,19 @@ Every meta-ref mutation is an author-signed commit whose signature replicates with the repository and verifies offline in any clone. -A signature proves authorship, not placement, so an `Advance-ref:` trailer -binds the commit to the ref it was authored for, and adopting someone -else's commit onto a canonical ref is always a merge, never a +A signature proves authorship, not placement, so the refname is bound by +recomputation from the commit's own signed content — a genesis oid, a +natural-key tree field, a composite of fields and signer — and adopting +someone else's commit onto a canonical ref is always a merge, never a cherry-pick, so the author's signature survives intact in ancestry. === 5. Gate Verification is a pure function over ref-store reads — is the new tip -signed by a member authorized for this refname, does its `Advance-ref:` -trailer match, does it descend from the old tip, does the update commit -via atomic CAS — evaluated identically at three call sites: hosted CAS +signed by a member authorized for this refname, does the refname +recompute from its signed content, does it descend from the old tip, +does the update commit via atomic CAS — evaluated identically at three +call sites: hosted CAS (mandatory, failure aborts the write), local UI verdict (advisory), and push pre-flight (advisory).