spec: bind every meta-ref name to its signed content, retiring Advance-ref
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
No reviews of this commit yet — record a verdict below.
Start a review
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).