Extending an entity MUST mean changing its #[derive(Facet)] struct and
recompiling the tools that interpret it; runtime-defined schemas MUST
NOT be treated as a v1 goal.
A facet shape MUST be compile-time-defined: runtime-readable, never
runtime-constructible.
This is possible because the gate and receive are content-agnostic —
verification depends on signature, refname, trailer, and DAG descent
(<<gate.tip-signed>> through <<gate.fast-forward>>), never on tree
contents — so a stock server MUST be able to carry entity types it
cannot parse; only the clients that interpret a given entity type need
to recompile.
docs/spec/model.adoc
The Entities
The typed trees meta-ref.adoc describes are not empty shapes; each one
carries specific forge semantics.
This file specifies that semantics — what each entity must mean, not its
field-by-field Rust representation.
An entity’s presentation policy — which fields render and in what
order, which lead a listing, which are omitted when empty, which
values are ids (abbreviated for humans, full in machine-readable
output), and which field is the body — MUST be declared once, as
ents attributes on the fields of the entity’s own #[derive(Facet)]
shape (<<model.extensibility>>), and every surface MUST consume it by
walking that shape generically: no surface may re-encode per-entity
presentation policy in its own code, and rendering MUST NOT branch on
the concrete entity type.
A surface MAY append a genuinely domain-specific line — a comment’s
projected anchor, a review’s thread — beside the generic walk, never
in place of it.
An action field composed in the editor when its flag is omitted MUST
likewise be declared on the action’s own shape; the same declaration
selects the web form’s long-text control (<<lens.forms>>).
''
Member
A member is a public key enrolled into the forge’s trust set; every signed commit’s authority ultimately traces back to a member entity.
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.
Revoking a member MUST record a revoked state on the member entity and MUST NOT delete the entity. Admission MUST consult the member entity currently in force — the tip of the member’s ref in the same snapshot the gate reads (<<gate.tip-signed>>) — so a revoked key’s new pushes are refused from the moment the revocation lands, regardless of any committer timestamp the pushed commit claims. A ref accepted before the revocation landed MUST remain valid: acceptance is never re-judged. The moment of acceptance is witnessed by the deployment’s op log, which is out of scope for this specification and for every crate in this repository; verifying what a key signed while it was valid is an audit function over that witness and the retained entity history, never a gate admission path. The entity MUST be retained rather than deleted precisely so that audit stays possible. Unrevoking a member MUST be supported, returning the key to authorizing new signatures without altering the record of the period it was revoked.
A Member entity MUST record how it was enrolled: admin-registered, or self-attested through a frontend that lets a key enroll itself. A self-attested member MUST NOT be authorized for canonical refs — its writes are limited to its own inbox and self-run namespaces (<<meta-ref.inbox>>) — until an admin-registered member promotes it, as an ordinary signed mutation of the member’s ref. A rule that says "admin-registered" (<<effect.admin-only>>) refers to this provenance.
A machine actor (a CI worker or other automated signer) MUST be represented as an ordinary Member entity: an enrolled key, authorized only for the refnames its role needs (such as its own results namespace), and revocable exactly like a human member’s key. A privileged write path for a machine actor MUST NOT exist outside this model.
Comment
A Comment entity MUST carry a body and MUST identify what it is about:
an anchor into content (<<anchor.definition>>), a context entity
(<<model.comment-context>>), a parent comment (<<model.comment-thread>>),
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 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.identity-binding>>).
Anchor resolution, projection onto other revisions, and reachability are
specified in anchor.adoc and apply to a Comment’s anchor unchanged.
A Comment entity MUST carry a state; a new comment’s state MUST be
open.
Resolving a comment MUST record state resolved as an ordinary mutation
commit on the comment’s own ref — never a deletion, so the conversation
stays auditable — and reopening MUST be supported the same way.
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.identity-binding>>), never from stored fields.
A mutation that changes a comment’s state MUST, when the mutating tool’s signing key matches an enrolled member’s stored key, carry a Key-for-<member-id> trailer in its commit message.
The trailer’s value MUST be the member ref’s tip commit oid at mutation time, pinning the resolver’s whole enrolled record — key, state, provenance — into the chain, so which member acted, and under which key, stays answerable after any later key rotation or revocation (<<meta-ref.identity-binding>>).
A signer with no enrolled member writes no trailer; the mutation itself is still recorded.
The trailer is the writing tool’s duty; the gate stays content-agnostic (<<model.comment-state>>).
A Comment MAY name a context: the canonical ref path below refs/meta/
of the entity it belongs to, such as issues/<id> or reviews/<id>.
An entity’s thread MUST be an aggregation query over comment refs
matching that context; a context entity MUST NOT store a list of its
comments (<<meta-ref.granularity>> — decomposed refs, aggregated views),
so two people commenting on the same issue concurrently never race a
shared ref.
A Comment MAY name a parent comment by id, making it a reply; the parent MUST exist when the reply is created. A reply MUST NOT be required to repeat its parent’s anchor or context: its aboutness is inherited from its thread root for display and projection. A thread MUST be an aggregation query over comment refs naming ancestors in the thread; no comment stores a list of its replies.
Issue
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; the CLI’s plain
(human-facing) display and the web UI abbreviate ids the way git
abbreviates commit oids, but a machine-readable form — the --porcelain
flag included — MUST carry the full id, since that is what lens.parity
requires to be "sufficient for an agent to enumerate and resolve."
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.
This field list MAY be extended — extending the issue model is a
typed-tree change, not a platform request (<<model.extensibility>>).
Review
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/<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.
A review’s verdict MUST be one of approve, request-changes, or
comment — a hard enum, unlike issue and comment states
(<<model.issue>>, <<model.comment-state>>): a verdict gates decisions,
so its vocabulary is platform, not schema.
A review’s discussion MUST be Comment entities naming the review as
their context (<<model.comment-context>>), anchored into the reviewed
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.identity-binding>>); the reviewer needs no tree field,
being both a refname segment and the signer the gate checks
(<<gate.owner-mutation>>).
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
for as long as the review exists.
This is the commit-level counterpart of <<anchor.retention>>, which can
embed blobs but has no reachability edge for commits — a gitlink is not
one.
Re-reviewing after the target moves MUST advance the pin fast-forward
with a new signed commit whose parents are the previous pin tip and the
newly reviewed commit, so every reviewed round stays retained and the
pin’s own history is the audit trail of exactly what was reviewed, when.
A pin commit carries no entity: its tree MUST be the empty tree, the
sole deliberate exception to <<meta-ref.namespace>>'s
tree-is-the-entity shape.
Effect
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 separate field.
It MUST NOT carry executor, sandbox, or retry fields; how an effect runs
is a deployment property (<<effect.deployment-property>>).
Result
A Result’s status MUST be one of pass, fail, or error.
When each status is written — and when nothing is written at all — is
run semantics, specified in <<effect.result-taxonomy>>.
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
A Toolchain entity MUST be a hash-pinned manifest of roughly 1KB stored
under refs/meta/toolchains/<name>, carrying the provenance needed to
reproduce the execution environment it names.
Who may touch the bytes it references is run semantics, specified in
<<effect.toolchains>>.
A toolchain is a resource an effect declares as a dependency
(<<model.effect-definition>>), never a trigger condition in its own
right.
Redaction
A Redaction entity MUST be a signed entity under refs/meta/redactions/*
carrying the target object’s oid, a human-readable reason, and the
admin signature authorizing the yank (<<receive.redaction-admin-only>>).
It MUST NOT carry the redacted content itself.
Enforcement at ingest and the reader-facing marker are receive and
meta-ref semantics, specified in <<receive.redaction-ingest>> and
<<meta-ref.tip-invariant>>.
Account
An Account entity MUST link a member’s key to a login identity. Authentication state MUST live in the repository as ordinary forge state; a session database or token table MUST NOT back it.