docs: rewrite spec around the six abstractions
commit
b10af33docs: rewrite spec around the six abstractions
Replace the nine old spec files with ten organized by abstraction (overview, meta-ref, model, anchor, gate, query, receive, effect, sync, roots), per docs/abstractions.adoc and the 13-crate architecture. Old rule IDs are retired wholesale; known gaps carry explicit (Deferred) markers so tracey uncovered output stays signal.
removes: checks/cli/conformance/server/signed-push/web spec files Assisted-by: Claude:claude-sonnet-5 Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
docs/spec/anchor.adoc
@@ -1,63 +1,74 @@
== The Anchor
-A durable pointer into content: a blob (and optionally a line range) at a
-commit.
-Anchors resolve and project independently of comments, so other tools
-(reviews, TODO trackers, blame overlays) can reuse them.
+A durable pointer into source: a blob, an optional line range, and a
+specific commit.
+Anchors resolve and project independently of any consumer — comments are
+merely the first client; reviews, TODO trackers, and blame overlays can
+reuse the same mechanism.
-[role="requirement", id="comments.anchor"]
-.Anchors
+[role="requirement", id="anchor.definition"]
+.Anchor Identity
--
-A comment MUST be anchored to the exact content it was written against: the
-commit, the repository-relative file path, the file's blob object id, and an
-optional 1-based inclusive line range (absent for a whole-file comment).
-An anchor is authoritative at creation and MUST NEVER be mutated afterwards.
-Creating an anchor MUST validate the path and line range against the
+An anchor MUST identify the exact content it was captured against: the
+commit, the repository-relative file path, the file's blob object id, and
+an optional 1-based inclusive line range, absent for a whole-file anchor.
+Creating an anchor MUST validate the path and the line range against the
revision's actual content.
-The anchored text is fully derivable from the blob and the line range and
-MUST be derived at read time, never stored redundantly.
-The anchored commit is recorded on a best-effort basis only (see
-<<anchor.reachability>>): nothing keeps it from being garbage collected, and
-an anchor MUST remain valid — its exact text still derivable, its position
-still projectable via a fallback — after it is gone.
--
-[role="requirement", id="comments.projection"]
+[role="requirement", id="anchor.immutable"]
+.Anchors Are Never Mutated
+--
+An anchor is authoritative at creation and MUST NEVER be mutated afterward.
+Its anchored text MUST be fully derivable from the blob and the line range
+and MUST be derived at read time, never stored redundantly.
+The anchored commit's id MUST be recorded only as a plain data field
+(<<anchor.retention>>); nothing in the anchor mechanism itself pins it, so
+it MAY be garbage collected once nothing else keeps it reachable.
+--
+
+[role="requirement", id="anchor.retention"]
+.Embedded Retention
+--
+The document storing an anchor MUST embed the anchored blob, referenced by
+the existing blob's own object id rather than copied, and a context blob of
+the surrounding source lines, written fresh, as ordinary tree entries in
+the document's own stored tree.
+This embedding MUST keep the anchored content reachable from
+`refs/meta/*` — surviving force-push, branch deletion, and gc — for as
+long as the document's ref exists, with no gc special-casing and no
+pinned ancestry; content addressing makes the embed free.
+This MUST NOT use a gitlink (mode `160000`): a gitlink names a commit in
+another repository and is not itself a reachability edge, so it would keep
+nothing reachable.
+Redaction (<<receive.redaction-ingest>>) is the sole deliberate exception to
+this retention.
+--
+
+[role="requirement", id="anchor.projection"]
.Anchor Projection
--
-Displaying a comment against any revision other than its own MUST be a
-read-time projection of its anchor, reporting one of four outcomes:
-*current* (the exact blob still sits at the anchored path), *relocated* (the
-file moved and/or the range shifted past edits outside it, reported with the
-new path and range), *outdated* (an edit touched the anchored region, or the
-entry is no longer a regular file), or *deleted* (the file is gone).
+Displaying an anchor against any revision other than its own MUST be a
+read-time projection reporting one of four outcomes: *current* (the exact
+blob still sits at the anchored path), *relocated* (the file moved and/or
+the range shifted past edits outside it, reported with the new path and
+range), *outdated* (an edit touched the anchored region, or the entry is no
+longer a regular file), or *deleted* (the file is gone).
Projection MUST follow renames and MUST work between any two commits —
-forwards, backwards, or across unrelated history, as long as the anchor's own
-commit still exists.
-Once the anchor commit has been garbage collected, projection MUST fall back
-to fuzzy-matching a small retained window of surrounding source lines against
-the target commit's version of the same path, reporting *relocated* (with the
-matched line range) on a good match, *outdated* on a poor one, and *deleted*
-when the path itself is gone — the same four outcomes, recovered
-approximately rather than exactly.
-An outdated or deleted projection MUST NOT lose the comment: the original
-anchor remains displayable.
-Projection is read-only and MUST NEVER mutate the stored anchor, in either
-the exact or the fallback path.
+forwards, backwards, or across unrelated history — as long as the anchor's
+own commit still exists.
+Projection MUST NEVER mutate the stored anchor.
--
-[role="requirement", id="anchor.reachability"]
-.Anchor Reachability
+[role="requirement", id="anchor.fuzzy-fallback"]
+.Fallback When the Anchored Commit Is Gone
--
-The content an anchor names — the anchored blob and a small window of
-surrounding source lines — MUST be embedded as ordinary tree entries in the
-consuming document's own stored tree (a reference to the existing blob's
-object id, not a copy; the surrounding-lines window written fresh), so both
-stay reachable from `refs/meta/*`, and so survive force-push, branch
-deletion, and gc, for as long as the consuming document's ref exists. This
-MUST NOT use a gitlink (mode `160000`): a gitlink names a commit in another
-repository and is not itself a reachability edge, so it would not keep
-anything reachable. The anchored commit's id is retained only as a plain data
-field (see <<comments.anchor>>) and is NOT pinned by this mechanism; it MAY
-be garbage collected once nothing else keeps it reachable.
+Once the anchored commit has been garbage collected, projection MUST
+degrade to fuzzy-matching the retained context blob (<<anchor.retention>>)
+against the target commit's version of the same path, reporting
+*relocated* (with the matched line range) on a good match, *outdated* on a
+poor one, and *deleted* when the path itself is gone — the same four
+outcomes, recovered approximately rather than exactly.
+An outdated or deleted projection MUST NOT lose the anchor: the original
+anchor remains displayable.
--
docs/spec/meta-ref.adoc
@@ -1,291 +1,97 @@
== The Meta-Ref
-One entity per ref under `refs/meta/*`.
-The meta-ref is simultaneously the unit of storage, sync, authorization, and
-history; the typed tree (a `Facet` struct mapped to a git tree) is what lives
-behind it.
-The entities below are all instances of this one abstraction.
+A ref under `refs/meta/*` is simultaneously the unit of storage,
+synchronization, authorization, and history for one piece of forge state.
+The typed tree — a `#[derive(Facet)]` struct mapped directly onto a git
+tree by `facet-git-tree` — is what lives behind it.
+The entities that live on meta-refs (members, comments, effects, results,
+toolchains, accounts) are specified in `model.adoc`; this file specifies
+the ref and the tree, not what they hold.
-[role="requirement", id="storage.bare"]
-.Persistent Bare Repository
---
-Repository state MUST be persisted in a bare Git repository on durable
-storage, created automatically on the first push to any previously unused
-name and never deleted by the server.
---
+'''''
-[role="requirement", id="storage.meta-ref"]
-.Meta-Ref Documents
---
-All structured server-side state (members, configuration, checks, run
-results, issues, comments, account profiles) MUST be stored as typed
-documents on dedicated `refs/meta/*` refs, one ref per document or per
-entity, using the `facet-git-tree` serialization: each document becomes a git
-tree, wrapped in a commit parented on the ref's prior tip, so every write is
-a fast-forward and every ref's commit chain is the document's full history.
+=== Namespace and Granularity
-A document's `Facet` shape IS its on-disk format.
-An incompatible change (a renamed field, a changed field type) silently
-breaks reading data already on a ref.
-Each document type MUST carry a load test against a hand-built fixture in
-the exact on-disk layout.
+[role="requirement", id="meta-ref.namespace"]
+.The refs/meta/* Namespace
+--
+All forge state MUST live under `refs/meta/*`.
+A meta-ref MUST point at a commit whose tree is the entity itself, so the
+ref is simultaneously the unit of storage (the commit's tree),
+synchronization (fetching or pushing the ref moves exactly that entity),
+authorization (refname-keyed rules gate who may advance it), and history
+(its commit chain is the audit trail).
--
-[role="requirement", id="storage.concurrency"]
-.Concurrent Writes
+[role="requirement", id="meta-ref.granularity"]
+.Granularity Rule
--
-Concurrent writes to the same `refs/meta/*` ref MUST use compare-and-swap.
-When a write finds the ref has moved since it was read, the writer MUST
-attempt a structural three-way merge of the document (base / ours / theirs):
-non-overlapping changes — different fields, or different entries of a
-collection — MUST merge cleanly.
-A genuine conflict (the same scalar changed two different ways) MUST fail
-cleanly so the caller can reload and reapply.
-Data MUST NEVER be silently overwritten and a real conflict MUST NEVER be
-silently resolved by picking a winner.
-An in-place state advance (a run's progression) is a deliberate replace and
-MUST fail cleanly on a race rather than merge.
+A meta-ref MUST hold exactly one independently-authored entity:
+`refs/meta/member/*`, `refs/meta/comments/*`, `refs/meta/effects/*`, and
+`refs/meta/results/*` each decompose one ref per entity.
+Repository-global state with a single writer-of-record MUST instead live
+on one fixed ref, such as `refs/meta/account` or `refs/meta/config`.
+Entities that different actors write concurrently MUST NOT share a ref.
+Writes to a meta-ref MUST stay conflict-free — no two concurrent writers
+ever race the same compare-and-swap — and reads aggregate multiple refs
+into a view rather than depending on a shared, contended one.
--
-=== Invariants
-
-How `git-store` (the crate implementing <<storage.meta-ref>> and
-<<storage.concurrency>>) makes the storage invariants explicit in code, so
-the next module reuses them instead of re-inventing them.
-Documented, not mandated.
-
-Key strategy::
- Every document is one of three shapes.
- A *singleton* lives on one fixed ref (`config`, `account`,
- `revoked`, `issue-number`): `Store::load`/`store`.
- A *named collection* is one-ref-per-item under a namespace prefix
- (`member/<username>`, `toolchains/<name>`, `effects/<name>`,
- `results/<effect>/<short-oid>`, `cache/<name>`) or a scalar-keyed map on a
- single ref
- (`revoked/<fingerprint>`): `Store::load_item`/`store_item`,
- `load_map`/`store_map`.
- A *content-addressed* collection is keyed by the hash of its own content
- (`issues/<id>`, `comments/<id>`) via the shared `git_store::new_id`
- (origin-or-content-hash), so filing an item never contends a counter and
- the identity rule cannot drift between collections.
-
-Raw trees::
- A field holding an arbitrary directory (a toolchain's `src`, or an
- embedded `bin`) is a `facet_git_tree::RawTree` — a passthrough wrapping an
- already-written tree's object id.
- `Store::store_tree`/`ref_tree` write and read the document's root tree
- directly, since such a subtree must exist in the object database before
- the document referencing it can be assembled.
-
-Authored collections::
- A collection whose documents treat the commit as the record
- (`comments/<id>`) writes through `Store::store_item_authored`, stamping
- the acting human on the ref's commit, and reads authorship back through
- `Store::provenance`/`item_provenance`.
- Neither an author nor a timestamp field is duplicated into the document
- tree.
-
-Collection-key safety::
- `git_store::ref_segment_ok` is the one place a collection key is checked
- before it becomes a ref path segment or tree entry name: 1-64 ASCII
- alphanumerics, `.`, `_`, `-`, or `:`, never starting with `.`, never
- containing `/`.
- `Store::store_item`, `store_keyed`, and `store_map` all enforce it,
- failing with `Error::InvalidKey`.
-
-Domain validation::
- A type carrying an invariant the type system cannot express (a validity
- window must not be inverted) exposes its own `validate` and calls it from
- its own `store`, returning `git_store::Error::Invalid` — for every caller,
- not just the CLI command that builds the value today.
-
-Closed sets are enums::
- A value the spec enumerates (`Issue.state`, a run's status) is a
- facet-derived enum, not a `String`, so an invalid value cannot be
- constructed.
-
-=== Members
-
-[role="requirement", id="members.ref"]
-.Member Refs
+[role="requirement", id="meta-ref.inbox"]
+.Inbox and Self-Run Namespaces
--
-The push trust root MUST be the set of refs matching `refs/meta/member/*`;
-each ref `refs/meta/member/<username>` holds one `Member` document for the
-person named by the ref's last segment.
-The set is decomposed — one ref per person — so adding, refreshing, or
-revoking a member is an independent, separately-historied operation.
+`refs/meta/inbox/*` MUST hold entities authored by someone not authorized
+for the corresponding canonical ref, awaiting adoption.
+`refs/meta/results/~<member>/<effect>/<short-oid>` MUST hold results a
+member produced on their own executor rather than a designated worker,
+mirroring the canonical results pattern (<<effect.results-writeback>>)
+under the member's own namespace.
+Both namespaces MUST hold the same typed trees as their canonical
+counterparts (<<meta-ref.typed-tree>>); only the refname rule differs.
--
-[role="requirement", id="members.trust"]
-.Member Trust Modes
+=== The Typed Tree
+
+[role="requirement", id="meta-ref.typed-tree"]
+.The Struct Is the Schema
--
-A member's trust MUST rest on exactly one of three mutually exclusive bases:
-
-Keys::
- A set of leaf signing keys, mapping each fingerprint to its OpenSSH public
- key.
- The solo and small-team default.
-
-Certificate Authority::
- A pinned certificate authority's OpenSSH public key; any certificate the
- CA issues for the member's principal, within the certificate's own
- validity window, is trusted.
- Rotation, expiry, and new devices require no edit to the member ref.
-
-WebAuthn::
- A set of passkey credentials in CASE form, keyed by credential ID, each
- with a human-readable label.
- WebAuthn credentials authorize browser sign-in only and MUST NOT produce
- `allowed_signers` lines or authorize git push.
+A Rust struct annotated `#[derive(Facet)]` MUST be the storage schema for
+its entity: `facet-git-tree` MUST map the struct directly onto a git
+tree, with no serialization format interposed that could version
+independently of the struct itself.
+A tree MUST stay a pure representation of its struct; it MUST NOT carry
+a version-marker entry.
--
-[role="requirement", id="members.provenance"]
-.Member Provenance
+[role="requirement", id="meta-ref.trailers"]
+.Reserved Commit Trailers
--
-Every member MUST carry a `provenance` recording whether they were
-admin-registered or self-attested via web onboarding; a member ref written
-before this field existed MUST load as admin-registered.
-A self-attested member MUST be granted limited trust: the web service MUST
-refuse their writes outside issues and comments (<<web.comments>>), and they
-MUST NOT be trusted for signed git push, until an admin promotes them.
---
-
-[role="requirement", id="members.account"]
-.Account Link
---
-A member MAY carry an optional `account` field `@`-mentioning their account
-repository by its genesis identity (<<account.genesis>>), so the link
-survives the account repository moving or being renamed.
-An absent field means no account is linked.
+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 `Ents-Ref:`, for binding the commit
+to the refname it was authored for.
--
-[role="requirement", id="members.window"]
-.Trust Window
+=== Tip Invariant and Migration
+
+[role="requirement", id="meta-ref.tip-invariant"]
+.Tip Invariant
--
-A member MAY carry an optional `valid-after` / `valid-before` window,
-expressed as OpenSSH timestamps (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`).
-An un-refreshed member whose window has lapsed MUST stop authorizing new
-pushes — stale trust fails closed.
-A previously-valid push MUST remain verifiable forever by pinning the
-verification time to the push date via `ssh-keygen -Y verify -Overify-time`.
+The tip of a meta-ref MUST always be readable by the current binary;
+only history is archival.
+A reader that encounters a withheld (redacted) object while reading a
+meta-ref's tip MUST surface a redaction marker, never an error
+(<<receive.redaction-ingest>>) — the sole qualification to this
+invariant.
--
-[role="requirement", id="members.allowed-signers"]
-.Allowed Signers Rendering
+[role="requirement", id="meta-ref.migration"]
+.Migration as Commit
--
-The server MUST render the live member set as an OpenSSH `allowed_signers`
-file for `ssh-keygen -Y verify`: the principal column a wildcard (`*`), each
-member's validity window as comma-joined options (`valid-after`,
-`valid-before`), each leaf key on its own line, a pinned CA as a
-`cert-authority` line, and every line carrying `namespaces="git"`.
---
-
-=== Revocations
-
-[role="requirement", id="revocations.ref"]
-.Revocation List
---
-A single ref, `refs/meta/revoked`, MUST hold the revocation list: a map from
-fingerprint to a free-text reason.
-This is the "faster than expiry" override: the server MUST subtract every
-revoked fingerprint from the trust set before verifying a push.
---
-
-[role="requirement", id="revocations.ca"]
-.CA Revocation
---
-Revoking a certificate authority MUST be done by removing the CA member's
-ref.
-A CA is named by its ref, not a fingerprint, so the revocation list operates
-only on leaf-key fingerprints.
---
-
-=== Account
-
-[role="requirement", id="account.ref"]
-.Account Profile
---
-A repository becomes an account repository by carrying a `refs/meta/account`
-ref.
-The `Account` document at that ref MUST hold: `username`, `display_name`,
-`bio`, and `created_at` (seconds since the Unix epoch).
-The username is authoritative in the document; the repository path is
-convention, not trust.
---
-
-[role="requirement", id="account.genesis"]
-.Account Identity
---
-An account's stable, path-independent identity MUST be the content hash of
-the first `Account` document ever recorded on its ref — fixed at creation,
-so later profile edits never change it and a reference to the account
-(<<members.account>>) survives the account repository moving.
-The identity MUST be derived from the ref's history, never stored as a
-field.
---
-
-=== Config
-
-[role="requirement", id="config.ref"]
-.Configuration Ref
---
-A repository's loose metadata MUST be stored at `refs/meta/config` as a
-`Config` document with fields: `description`, `homepage`, and `topics`.
-An absent ref MUST yield the default (all empty).
-Metadata on a meta ref means a content push cannot rewrite it, and it
-carries its own independent history.
---
-
-=== Issues
-
-[role="requirement", id="issues.ref"]
-.Issue Documents
---
-Each issue MUST be stored at `refs/meta/issues/<id>` as an `Issue` document
-with fields: `title`, `body`, `state` (`open` or `closed`), `labels` (plain
-strings, no separate registry), `author`, and `id` (the friendly number
-below).
-One ref per issue keeps issues independently loadable and separately
-historied; the ref's commit chain is the issue's edit history.
---
-
-[role="requirement", id="issues.id"]
-.Issue Identity
---
-An issue's stable identifier MUST be a content hash — the object id of the
-originating object, or of the issue's own initial content when it has none
-upstream — and MUST be the issue ref's last path segment, never renamed.
-An issue MAY additionally carry a friendly sequential number, assigned only
-when a maintainer promotes it; only promotion advances the number counter,
-so filing an issue never contends it.
-Any cross-referencing feature MUST key off the stable content-hash
-identifier, not the friendly number.
---
-
-=== Comments
-
-[role="requirement", id="comments.ref"]
-.Comment Documents
---
-Each code comment MUST be stored at `refs/meta/comments/<id>` as a `Comment`
-document with fields: `body`, `anchor` (<<comments.anchor>>), and an
-optional `issue` cross-referencing an issue by its genesis id
-(<<issues.id>>); an absent `issue` means a free-standing comment.
-The identifier MUST follow the same genesis-key rule as an issue and is
-never renamed.
-One ref per comment keeps comments independently loadable and separately
-historied; the ref's commit chain is the comment's edit history.
-The document's stored tree additionally carries the retained blob and
-surrounding-lines window described in <<anchor.reachability>>; this is
-storage plumbing, not a `Comment` field, and is invisible to any reader of
-the document's public shape.
---
-
-[role="requirement", id="comments.authorship"]
-.Authorship From the Commit Chain
---
-A comment's author and timestamps MUST NOT be stored in the document tree.
-The creator is the author of the ref's first commit and the last editor is
-the author of its tip commit; both MUST be recovered from the commit chain
-at read time.
+Changing an entity's struct MUST be performed as a storage migration:
+rewrite the tree under the new struct and commit it on top of the ref's
+old tip, signed like any other mutation.
+History MUST keep the old encoding as archive; a struct change MUST NOT
+rewrite or delete a prior commit on the ref.
--
docs/spec/overview.adoc
@@ -13,16 +13,217 @@
`docs/abstractions.adoc`), each a parent section with its dependent entities
as children:
-* `meta-ref.adoc` — the meta-ref and the typed tree; the entities stored on
- them (members, revocations, account, config, issues, comments).
+* `meta-ref.adoc` — the meta-ref and the typed tree.
+* `model.adoc` — the entities stored on meta-refs: members, comments,
+ effects, results, toolchains, and accounts.
* `anchor.adoc` — durable pointers into content, and their projection.
-* `signed-push.adoc` — the only write path, for git and browser alike.
-* `checks.adoc` — checks and the toolchains that feed them.
-* `server.adoc` — the embeddable server: protocol, namespace, compatibility,
- deployment, nonfunctional bounds.
-* `web.adoc` — the web UI and the rendering registry.
-* `cli.adoc` — the porcelain.
-* `conformance.adoc` — requirement → implementation → test map.
+* `gate.adoc` — the pure verify function and its three call sites.
+* `query.adoc` — the `CommitQuery` algebra that triggers effects.
+* `receive.adoc` — the one write path every frontend shares.
+* `effect.adoc` — effect execution, results, and toolchains at run time.
+* `sync.adoc` — meta-ref fetch/push, pre-flight, and divergence merge.
+* `roots.adoc` — the composition roots, the only place deployment exists.
[CAUTION]
This specification is not yet stable and will grow as the project develops.
+
+'''''
+
+== The Six Abstractions
+
+Everything else in this specification is an instance or a consequence of
+six load-bearing abstractions.
+This section orients; the normative requirements for each abstraction live
+in the file named alongside it, where one exists yet.
+
+=== 1. Meta-Ref
+
+A ref under `refs/meta/*` is simultaneously the unit of storage (the ref
+points at a commit whose tree is the entity), synchronization (fetch or
+push only the entities a client cares about), authorization (refname-keyed
+rules gate who may advance the ref), and history (its commit chain is the
+audit trail).
+One ref holds exactly one independently-authored entity, or one piece of
+repository-global state; see `meta-ref.adoc`.
+
+=== 2. Typed Tree
+
+A Rust struct annotated `#[derive(Facet)]` is the storage schema: no
+serialization format sits between the struct and the git tree
+`facet-git-tree` maps it to, and changing the struct is a storage
+migration — a signed commit, not a silent break; see `meta-ref.adoc`.
+
+=== 3. Anchor
+
+A durable pointer into source — a blob, an optional line range, and the
+commit it was taken against — embedded in the anchoring document's own
+tree so it survives force-push, branch deletion, and gc, and projected
+onto newer commits at read time by blame plus fuzzy matching, never
+mutated; see `anchor.adoc`.
+
+=== 4. Signed Commit
+
+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 `Ents-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
+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 `Ents-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
+(mandatory, failure aborts the write), local UI verdict (advisory), and
+push pre-flight (advisory).
+
+=== 6. Effect
+
+A declarative, content-addressed subscription to a commit-set query,
+whose execution is sandboxed and whose output re-enters the repository
+only as signed commits on a results ref.
+The trigger fires once per commit entering the query's set, work is
+monotone and entry-only, and the runner is a member, not an ambient
+authority, so an official result is a refname rule on canonical results
+refs rather than a runtime property of a blessed machine.
+
+'''''
+
+== Architecture
+
+The abstractions above compose into a crate graph with a small number of
+hard boundaries.
+Violating one of them is the design failing, not a detail; this section
+states them as requirements.
+
+=== Crate Graph
+
+NOTE: Crate names below are working names, not commitments.
+A `gix-*` crate extends gitoxide and imports nothing from the forge; it
+can ship as a real crate only by upstreaming into gitoxide, or by picking
+a different, unclaimed name.
+
+[cols="2,4,3", options="header"]
+|===
+| Crate | Responsibility | Depends on
+
+| `facet-git-tree`
+| Typed tree: struct ↔ git tree mapping, domain-blind.
+|
+
+| `gix-ref-store`
+| `RefStore` trait — reads plus atomic multi-ref CAS — and a loose-ref
+implementation.
+|
+
+| `gix-receive`
+| Smart-HTTP wire framing and pack ingestion, zero forge policy.
+|
+
+| `ents-model`
+| Entity structs, refname namespaces, trailers, status taxonomy.
+| `facet-git-tree`
+
+| `ents-query`
+| `CommitQuery` algebra.
+| `ents-model`, `gix-ref-store`
+
+| `ents-gate`
+| The pure verify function.
+| `ents-model`, `gix-ref-store`
+
+| `ents-receive`
+| `receive()`.
+| `ents-gate`, `ents-query`, `gix-ref-store`
+
+| `ents-anchor`
+| Anchor storage and projection.
+| `ents-model`
+
+| `ents-effect`
+| `Executor` trait, Docker and Sprite backends, the run loop.
+| `ents-model`, `ents-query`, `ents-receive`
+
+| `ents-sync`
+| Meta-ref fetch/push and own-heads merge.
+| `ents-gate`, `ents-model`, `facet-git-tree`
+
+| `ents-web`
+| The local and hosted web UI.
+| `ents-receive`, `ents-model`, `ents-anchor`, `ents-query`
+
+| `git-ents` (bin)
+| Local composition root.
+|
+
+| `git-ents-server` (bin)
+| Hosted composition root.
+|
+|===
+
+=== Boundary Rules
+
+[role="requirement", id="arch.no-object-store-trait"]
+.No Private Object-Store Trait
+--
+The library MUST NOT define its own `ObjectStore` trait.
+gitoxide's `gix_object::Find`, `Exists`, and `Write` traits ARE the
+object-store seam.
+A new trait MUST be introduced only where gitoxide is silent — the
+pluggable ref store, server-side receive framing, and reachability
+artifacts are the seams that qualify.
+--
+
+[role="requirement", id="arch.gate-receive-split"]
+.Gate and Receive Are Separate Crates
+--
+The gate's pure verify function MUST live in a crate separate from
+`receive`.
+The gate has three call sites — hosted CAS, local UI verdict, and push
+pre-flight — and the latter two MUST be answerable without linking
+`receive`'s effect-matching and enqueue logic.
+--
+
+[role="requirement", id="arch.query-effect-split"]
+.Query and Effect Are Separate Crates
+--
+The `CommitQuery` algebra MUST live in a crate separate from executor and
+run-loop code.
+`receive` depends on the query crate for footprint matching on every push
+and MUST NOT depend on the effect crate, so no push path links executor
+code.
+--
+
+[role="requirement", id="arch.store-composition-root"]
+.Store Implementations Live in Composition Roots
+--
+A concrete store implementation — Postgres, Tigris, SQLite, or any other
+backing store — MUST be wired only inside a composition root, never
+inside a library crate.
+It MUST NOT be promoted into a shared library crate until a second
+composition root consumes it.
+--
+
+[role="requirement", id="arch.no-hosted-branch"]
+.No Hosted Branch in Library Code
+--
+A library crate MUST NOT contain a branch on deployment mode — an
+`if hosted`-shaped check, or equivalent.
+Deployment MUST exist only in composition roots, which wire trait
+implementations together.
+--
+
+[role="requirement", id="arch.refstore-read-cas-split"]
+.RefStore Splits Reads From CAS
+--
+The `RefStore` trait MUST separate its read operations from its atomic
+multi-ref compare-and-swap operation.
+The gate MUST depend on only the read half, since verification never
+performs a write.
+--
+
+The honesty test for these boundaries — a third single-node composition
+root constructible from library crates alone — is a deployment
+requirement and lives in `roots.adoc` as <<roots.honesty-test>>.
docs/spec/checks.adoc
@@ -1,172 +1,0 @@
-== The Check
-
-A check is data (`refs/meta/effects/<name>`), not configuration living
-outside the repo; the toolchain that feeds it is a git tree
-(`refs/meta/toolchains/<name>`).
-The repository carries its own CI definition and environment, and nothing
-ever blocks a push.
-
-[role="requirement", id="checks.definition"]
-.Check Set Definition
---
-The configured checks for a repository MUST be stored one ref per check at
-`refs/meta/effects/<name>`, each holding an optional shell command, an
-optional sandbox image, an optional list of dependencies (names of sibling
-checks that must pass first), an optional list of toolchain names
-(<<checks.toolchains>>), and an optional cache name (<<checks.cache>>).
-A check with no command is a composite: it runs nothing itself and derives
-its outcome from its dependencies alone.
-A check's definition living on a meta ref means a branch under check cannot
-rewrite the check set that gates it; decomposing the set one ref per check
-means each check can be added or removed as its own independently-signed
-push, and the admin-only write rule can be stated as a single refname glob
-(`refs/meta/effects/*`) rather than gating one shared ref.
-
-The dependency graph is fully static — no conditional edges, no runtime
-expansion — and MUST be validated when the set is written: a dependency
-naming no configured check, a duplicate or self edge, a check with neither
-a command nor dependencies, any dependency cycle, and a toolchain or cache
-name that is not a valid ref-path segment MUST each be rejected before the
-set is stored.
-A check that sets an image MUST be rejected until the sandbox can honor
-one; the field is reserved in the format so honoring it later is not a data
-migration.
---
-
-[role="requirement", id="checks.admin-only"]
-.Effect Definitions Are Admin-Only
---
-`pre-receive` MUST reject a push to `refs/meta/effects/*` from a member
-whose provenance is not admin-registered, regardless of `refs/meta/config`'s
-role rules: authoring an effect schedules code execution, which needs more
-trust than an ordinary branch push, so this rule MUST be enforced
-explicitly rather than left to a role a repository may or may not have
-configured.
---
-
-[role="requirement", id="checks.cache"]
-.Effect Cache
---
-An effect's definition MAY name a persisted cache: a directory restored into
-the sandbox at a fixed path before the command runs and snapshotted back to
-`refs/meta/cache/<name>` after, unlike a toolchain (<<checks.toolchains>>),
-which is immutable and extracted once. The worker MUST restore a named
-cache's most recent snapshot (creating an empty directory when none exists
-yet) before any effect naming it runs, and MUST snapshot the directory back
-after the command finishes, regardless of its outcome, so a partial cache
-from a failed run is still available to the next one.
-The command MUST be able to find the restored directory's path via the
-`EFFECT_CACHE_DIR` environment variable.
-Persisting the cache through `git-store` rather than relying solely on the
-Sprite's own persistent filesystem means the cache survives a Sprite reset
-or migration, and is auditable and portable like every other meta-ref
-document.
---
-
-[role="requirement", id="checks.toolchains"]
-.Check Toolchains
---
-A check's optional list of toolchain names MUST each be a valid ref-path
-segment, validated when the set is written; whether a named toolchain
-actually exists MUST be checked server-side at job time instead, since it
-names an entry in a different ref namespace (`refs/meta/toolchains/<name>`,
-`git-toolchain`) the check set itself does not enumerate.
---
-
-[role="requirement", id="checks.post-receive"]
-.Asynchronous Queuing
---
-After a successful push is accepted by `pre-receive`, a `post-receive` hook
-MUST enqueue a check job for each updated branch whose new tip is non-zero
-and is not a `refs/meta/*` ref.
-The hook MUST record an initial run status of `queued` for each check
-immediately, so the UI reflects the job before the worker picks it up.
-The hook MUST NOT run checks itself; it MUST return as soon as jobs are
-enqueued.
-Job files MUST be written to a tmp path and renamed into place so the
-worker never observes a partial file.
---
-
-[role="requirement", id="checks.worker"]
-.Check Worker
---
-A persistent server-side worker MUST drain the job queue.
-Jobs MUST be grouped by repository; each repository's jobs MUST be
-processed serially to prevent concurrent runs from colliding in its
-sandbox, while separate repositories MUST be processed concurrently.
-
-Within a job the checks MUST settle in topological dependency order, so
-every dependency's outcome is terminal before its dependents are reached.
-A check whose dependency did not pass MUST be recorded `skipped` without
-running; a composite's outcome is derived from its dependencies — `pass`
-when all passed, `fail` when any failed or errored, `skipped` otherwise.
-The worker MUST re-validate the dependency graph before running (a
-hand-crafted push can land a set the CLI would have rejected) and finalize
-the run as `error` when it is invalid.
---
-
-[role="requirement", id="checks.sandbox"]
-.Sandbox
---
-Each check MUST run inside a sandbox, behind one execution abstraction
-with two backends: hosted, a Fly.io Sprite — a persistent,
-hardware-isolated sandbox, one per repository, so build caches survive
-between pushes — and local, a Docker container. Host-direct execution
-MUST require an explicit `--unsandboxed` opt-in.
-Before running checks the worker MUST sync the pushed tree into the
-sandbox's work directory via `git archive` piped to `tar -x`; the Sprite
-backend MUST configure the `sprite` CLI from `SPRITES_TOKEN` via
-`sprite auth setup`.
-
-Before running a check that names a toolchain, the worker MUST resolve each
-distinct toolchain named across the job's checks (<<checks.toolchains>>) to
-its typed `bin` (`Toolchain::bin`, not the whole document) and materialize
-it into the Sprite at a hash-keyed directory, skipping the work when that
-directory already exists — the Sprite's persistent filesystem is the cache.
-An embedded toolchain's tree is extracted from the object database; a
-downloaded toolchain's components MUST be fetched, sha256-verified against
-their recorded pins, and extracted *inside* the Sprite itself, each per its
-recorded layout (leading path segments stripped, landed in its recorded
-subdirectory, compression auto-detected), under a cache key derived from
-the component hashes plus their layout — the repository never proxies the
-bytes it deliberately does not store.
-A check's command MUST then run with each of its named toolchains'
-extracted `bin` directory prefixed onto `PATH`, in declaration order, so an
-earlier toolchain wins a name collision.
---
-
-[role="requirement", id="checks.outcomes"]
-.Run Recording
---
-Run outcomes MUST be stored at `refs/meta/results/<effect>/<short-oid>`, one
-ref per checked commit per check.
-Each ref's commit chain is that check's run history against that commit;
-each commit's date is the run time, so no timestamp is duplicated in the
-document tree.
-Outcome values progress: `queued` → `running` → `pass` / `fail` / `error` /
-`skipped` (per <<checks.worker>>).
-A run that cannot complete due to an infrastructure error MUST be finalized
-as `error` rather than left stuck at `running`; a single check exceeding a
-30-minute timeout MUST be recorded `error`.
-A check definition and a run outcome MAY each carry additional metadata (a
-run's duration and log URL); optional fields absent from an older record
-MUST load as unset.
-A run's checked commit MUST be recorded in full inside the record (not just
-the ref's abbreviated segment), so it can be recovered regardless of how the
-ref name truncates it; every check updated together by a single worker pass
-MUST be reassembled into one aggregate run by grouping their per-check
-records on that recorded moment.
---
-
-[role="requirement", id="checks.debug"]
-.Sprite Debug Shell
---
-A signed-in member MUST be able to open an interactive, read-write shell in
-a repository's effects Sprite (`git ents effect debug`), brokered by the
-server over a WebSocket at the reserved path `/_debug/<repo>`.
-The server holds the only Fly credential (`SPRITES_TOKEN`); a member MUST
-NOT need one of their own, and the broker MUST refuse the connection
-without a signed-in session.
-Local terminal resizes MUST be forwarded to the remote pseudo-terminal as
-control frames distinct from the byte stream.
---
docs/spec/cli.adoc
@@ -1,156 +1,0 @@
-== Client CLI
-
-The porcelain.
-Its added value over the underlying primitives is remotes: every command is
-a fetch, a typed edit, and a signed push — never a private API.
-
-[role="requirement", id="cli.remote-admin"]
-.Remote Administration Over Git
---
-The `git-ents` CLI MUST administer a remote's trust and metadata refs as
-ordinary signed git pushes, with no separate admin API or server endpoint.
-A mutating command MUST fetch the relevant `refs/meta/*` ref(s) from the
-named remote (defaulting to `origin`), load the typed document, apply the
-change, store it, and push the updated ref back — signed per the client's
-git config, through the same `pre-receive` gate a content push traverses.
-A read-only command (`members list`, `members check`, `effect list`,
-`comment list`/`show`) MUST only fetch, never push.
---
-
-[role="requirement", id="cli.interactive"]
-.Interactive Prompting
---
-A mutating command run at an interactive terminal MUST prompt for any
-required field left unset rather than fail.
-The same invocation without a TTY MUST fail with an error naming the
-missing field; it MUST NOT hang waiting for input.
---
-
-[role="requirement", id="cli.compare-and-swap"]
-.Optimistic Concurrency
---
-Every CLI push MUST be a compare-and-swap pinned to the ref tip observed at
-fetch time, expressed as `--force-with-lease=<ref>:<expected>` together
-with `--force-if-includes`, so a change made on the remote since the fetch
-is rejected rather than clobbered; a create pins the lease to the zero
-object id.
-The client-side counterpart to <<storage.concurrency>>.
---
-
-[role="requirement", id="cli.members"]
-.Member Commands
---
-The CLI MUST provide, under `git ents members`:
-
-* `list` — render the live member set, each key with its fingerprint,
- validity window, and a flag when it appears on the revocation list.
-* `add` — authorize a leaf key or pin a certificate authority for a
- username, with optional `valid-after` / `valid-before` window, creating
- the ref when absent.
-* `remove` — delete the member's ref.
-* `revoke` / `unrevoke` — add or remove a fingerprint on
- `refs/meta/revoked`.
-* `check` — report whether a key is a member of the remote and echo the
- client's signing config (`gpg.format`, `user.signingkey`,
- `push.gpgSign`).
-* `setup` — configure signed pushes (<<auth.client-setup>>).
-
-Revoking the operator's own last authorizing key MUST prompt for
-confirmation, since it can lock the operator out of the remote.
---
-
-[role="requirement", id="cli.account-checks"]
-.Account and Effect Commands
---
-`git ents account create` MUST write or update `refs/meta/account`
-(<<account.ref>>) and print the account's genesis identity
-(<<account.genesis>>).
-`git ents effect` MUST provide `list`, `add`, `remove`, `debug`, and `log`
-over the `refs/meta/effects/<name>` set (<<checks.definition>>). `add` MUST
-accept a `--cache <name>` naming a persisted cache (<<checks.cache>>) for
-the effect to restore before, and snapshot after, its command runs.
---
-
-[role="requirement", id="cli.toolchains"]
-.Toolchain Commands
---
-The CLI MUST provide, under `git ents toolchain`:
-
-* `import` — write a local `bin` directory (and, optionally, a `src`
- directory) plus a license, version, and platform to
- `refs/meta/toolchains/<name>` on a remote, creating the ref when absent.
- `bin` MUST be non-empty; `license` MUST be a valid SPDX expression;
- `version` MUST be valid semver; `platform` MUST be a valid target triple.
-* `import --from <recipe>` — derive the fields from a local toolchain
- install, or a hosted release, via a named recipe (`rustup`, `sccache`, or
- `url`, selected further by `--spec <name>`, e.g. `stable` for `rustup`, a
- release tag for `sccache`, the archive's URL for `url`); explicitly passed
- fields override the recipe. A recipe capable of pointing at a
- distributor's own hosted, hash-pinned archives (rust-lang's dist tarballs,
- for `rustup`) records those as the toolchain's `bin` by default, sparing
- the repository the bytes; `--embed` forces importing the local install's
- actual `bin` bytes, as `import` without `--from` always does.
-* `import --from <recipe> --platform <triple>` — `--platform` MUST
- additionally parameterize the recipe's own resolution, pinning the named
- target's hosted archives without the local machine ever holding that
- platform's binaries (`rustup` resolves version and components from the
- channel manifest alone), and MUST be rejected together with `--embed`.
- A recipe whose distributor publishes no hash manifest (`sccache`, `url`)
- MUST instead fetch the archive once at import time solely to compute the
- sha256 recorded as its pin — trust on first use, with the recipe string
- and the ref's commit history as the audit trail; every later fetch
- verifies against that pin. `url` pins any archive (`--spec <url>`,
- required) with its extraction layout from `--strip`/`--dest`, and
- supplies no version, platform, or license of its own.
-* `list` — render every toolchain on a remote with its `bin` (a tree id,
- or a component count when hosted externally), version, platform, and
- license.
-* `export` — recreate a remote toolchain's `bin` (and `src`, if present)
- under a local destination, restoring the executable bit and symlinks —
- fetching, sha256-verifying, and extracting externally hosted components
- first, each per its recorded layout (leading segments stripped, landed in
- its recorded subdirectory, compression auto-detected) — and print the
- version, platform, and license; read-only, per <<cli.remote-admin>>.
-* `remove` — delete the toolchain's ref on a remote.
---
-
-[role="requirement", id="cli.comments"]
-.Comment Commands
---
-The CLI MUST provide, under `git ents comment`:
-
-* `add` — anchor a comment to a path, an optional 1-based inclusive line
- range, and a revision (defaulting to `HEAD`), optionally attached to an
- issue by its genesis id, and push it.
-* `list` / `show` — project each comment's anchor onto a chosen revision
- and report its projection state (<<comments.projection>>); `show` MUST
- print the anchored text derived from the blob, and both MUST report the
- author recovered from the ref's commits (<<comments.authorship>>).
-* `remove` — delete the comment's ref.
-
-Comment commands MUST follow the same remote-administration flow as member
-commands (<<cli.remote-admin>>, <<cli.compare-and-swap>>).
---
-
-[role="requirement", id="cli.login"]
-.CLI Sign-In
---
-`git ents login` MUST complete the same challenge–response sign-in the
-browser uses (<<web.auth.challenge>>) with no manual copy-and-paste: fetch
-a challenge from the remote, sign it with the configured signing key,
-submit the proof, and receive the same session a browser sign-in yields.
-The server MUST expose the challenge and verify steps as plain-text
-endpoints suitable for non-browser clients.
---
-
-[role="requirement", id="cli.key-resolution"]
-.Key Resolution and Fingerprints
---
-The key a command operates on MUST default to `user.signingkey`, accepting
-an inline `key::` value, a `.pub` file, or a private key whose public half
-is derived with `ssh-keygen -y`; `members setup` MAY generate a new
-`~/.ssh/id_ed25519` when none exists.
-A key's fingerprint MUST be the MD5 colon form, whose separators are safe
-as a ref-path/tree-entry segment, unlike the slashes a base64 SHA256
-fingerprint would introduce.
---
docs/spec/conformance.adoc
@@ -1,85 +1,0 @@
-= Conformance
-
-A map from each requirement to the module or function implementing it and the
-test guarding it, so a requirement with no entry (or an entry marked
-_Planned_) is immediately visible as unimplemented rather than discovered by
-reading every module. Update this table in the same change that adds,
-renames, or removes a requirement or its implementation.
-
-[cols="1,2,2", options="header"]
-|===
-|Requirement |Implementation |Guarding test
-
-|`storage.bare` |`git-ents-server/src/http.rs` (`backend`, repo auto-init) |`server::push_then_clone_round_trip`
-|`storage.meta-ref` |`git-store/src/lib.rs` (`Store::load`/`store`) |per-document `loads_the_on_disk_*_format` fixture tests
-|`storage.concurrency` |`git-store/src/merge.rs` (`three_way_merge`), `Store::store_impl`/`amend` |`git-store::tests::merge_*`, `amend_fails_closed_on_a_race_instead_of_merging`
-|`server.embeddable` |`git-ents-server/src/lib.rs` (`pub` `Args`/`run`, hook subcommands); `git-ents/src/main.rs` (`Top::Server`) | _(structural)_
-|`protocol.git` |`git-ents-server/src/http.rs` (`backend`) |`server::push_then_clone_round_trip`
-|`protocol.routing` |`git-ents-server/src/http.rs` (`get_request`/`post_request`, `detects_pushes`) |`http::tests::detects_pushes`, `routes_browser_gets`
-|`namespace.url` |`README.adoc`/deployment config | _(documentation only)_
-|`namespace.path` |`git-ents-server/src/http.rs` (segment validation) |`http::tests::validates_segments`, `extracts_repo_path`
-|`namespace.auto-create` |`git-ents-server/src/http.rs` (`backend` init lock, `reconcile_head`) |`server::rejects_colliding_pushes`
-|`members.ref` |`git-member/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref`
-|`members.trust` |`git-member/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}`
-|`members.provenance` |`git-member/src/members.rs` (`Provenance`); `git-ents-server/src/web/write.rs` (`require_admin_registered`) |`members::loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered`, `web_edit::a_self_attested_member_is_refused_a_settings_edit`
-|`members.account` |`git-member/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member`
-|`members.window` |`git-member/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window`
-|`members.allowed-signers` |`git-member/src/members.rs` (`allowed_signers`, `member_lines`) |`members::renders_a_wildcard_allowed_signers_file`, `renders_the_validity_window_as_comma_joined_options`
-|`revocations.ref` |`git-member/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations`
-|`revocations.ca` |`git-member/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched`
-|`auth.bootstrap` |`git-signed-push/src/lib.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case)
-|`auth.signed-push` |`git-signed-push/src/lib.rs` (`verify_certificate`) |`pre_receive::rejects_an_unsigned_push_when_signers_exist`, `rejects_a_push_signed_by_an_unknown_key`
-|`auth.nonce` |`git-ents-server/src/http.rs` (`backend_config_injects_nonce_seed_and_hooks_path`) |`http::tests::backend_config_injects_nonce_seed_and_hooks_path`
-|`auth.client-setup` |`git-ents/src/main.rs` (`setup`, `ensure_key`) | _(manual/CLI UX, no automated test)_
-|`cli.remote-admin` |`git-ents/src/main.rs` (`sync`, `push_signed`) | _(manual/CLI UX, no automated test)_
-|`cli.compare-and-swap` |`git-ents/src/main.rs` (`push_signed`, `push_delete`, `--force-with-lease`) | _(manual/CLI UX, no automated test)_
-|`cli.members` |`git-ents/src/main.rs` (`Action`, `run_members`) | _(manual/CLI UX, no automated test)_
-|`cli.interactive` |`git-ents/src/interactive.rs` | _(manual/CLI UX, no automated test)_
-|`cli.account-checks` |`git-ents/src/main.rs` (`AccountAction`, `ChecksAction`) | _(manual/CLI UX, no automated test)_
-|`cli.comments` |`git-ents/src/main.rs` (`CommentAction`, `run_comment`) | _(manual/CLI UX, no automated test)_
-|`cli.login` |`git-ents/src/main.rs` (`login`); `git-ents-server/src/main.rs` (`/login/cli` routes) | _(manual/CLI UX, no automated test)_
-|`cli.key-resolution` |`git-ents/src/main.rs` (`fingerprint`, MD5 colon form) | _(manual/CLI UX, no automated test)_
-|`cli.toolchains` |`git-ents/src/main.rs` (`ToolchainAction`, `toolchain_import`); `git-ents/src/registry.rs` (`resolve`, `rustup`, `sccache`, `url_archive`) | _(manual/CLI UX, no automated test)_
-|`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo`
-|`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_
-|`config.ref` |`git-ents-core/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent`
-|`checks.definition` |`git-effect/src/definition.rs` (`load`/`store`, `EFFECTS_NS`, `order`) |`definition::tests::store_then_load_round_trips_an_effect`, `store_then_load_round_trips_image_and_depends`, `order_runs_dependencies_first`, `order_rejects_a_cycle`, `order_rejects_an_unknown_dependency`, `order_rejects_self_and_duplicate_edges`, `order_rejects_an_empty_effect`
-|`checks.cache` |`git-effect/src/cache.rs` (`CACHE_NS`, `cache_ref`, `cache_dir`, `restore`, `snapshot`); `git-effect/src/engine.rs` (`with_cache_env`) |`definition::tests::store_then_load_round_trips_cache`, `order_rejects_an_invalid_cache_name`; `engine::tests::with_cache_env_leaves_a_cache_free_command_unchanged`, `with_cache_env_exports_the_restored_directory` _(`restore`/`snapshot` themselves require a live Sprite; not covered by unit tests)_
-|`checks.post-receive` |`git-effect/src/engine.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_
-|`checks.worker` |`git-effect/src/engine.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`engine::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-effect`)_
-|`checks.sandbox` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`, `resolve_toolchains`, `downloaded_script`, `components_key`, `activate`) |`engine::tests::downloaded_script_extracts_each_component_per_its_layout`, `components_key_includes_the_layout`, `activate_prefixes_path_in_declared_order` _(live-Sprite paths not covered by unit tests)_
-|`checks.outcomes` |`git-effect/src/results.rs` (`Status`, `record`/`update_run`); `git-effect/src/engine.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`results::tests::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_recording`
-|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`effect debug`) | _(requires a live Sprite; manually verified)_
-|`checks.admin-only` |`git-signed-push/src/lib.rs` (`pre_receive`, `EFFECTS_NS_PREFIX`) |`git-ents-server::pre_receive rejects_a_push_to_effects_from_a_self_attested_member`
-|`issues.ref` |`git-ents/src/issues.rs` (`Issue`, `State`) |`issues::store_then_load_round_trips_an_issue`
-|`issues.id` |`git-ents/src/issues.rs` (`new_id`, `promote`) |`issues::new_id_hashes_its_own_content_with_no_origin`, `promotion_assigns_a_number_and_advances_the_counter_without_renaming_the_ref`
-|`comments.ref` |`git-comment/src/lib.rs` (`Comment`, `COMMENTS_NS`, `new_id`) |`git-comment::store_then_load_round_trips_a_comment`, `loads_the_on_disk_comment_format`, `new_id_hashes_its_own_content_with_no_origin`
-|`comments.authorship` |`git-store/src/lib.rs` (`store_item_authored`, `provenance`); `git-comment/src/lib.rs` (`provenance`) |`git-store::provenance_recovers_the_creating_and_updating_authors`, `git-comment::provenance_comes_from_the_commits_not_the_document`
-|`comments.anchor` |`git-anchor/src/lib.rs` (`Anchor`, `capture`, `snippet`, `context`) |`git-anchor::capture_records_the_commit_and_blob_and_snippet_derives_the_text`, `capture_rejects_a_missing_path_and_an_oversized_range`, `context_captures_a_margin_around_the_anchored_range`, `context_clamps_to_the_file_when_the_margin_would_overrun_it`, `context_of_a_whole_file_anchor_is_the_whole_file`
-|`comments.projection` |`git-anchor/src/lib.rs` (`project`, `project_from_context`, `Projection`) |`git-anchor::unchanged_file_projects_as_current`, `a_pure_rename_relocates_with_the_same_lines`, `an_edit_inside_the_range_is_outdated`, `a_deleted_file_projects_as_deleted`, `projection_works_backwards_onto_an_ancestor`, `project_from_context_relocates_across_an_edit_above_the_range`, `project_from_context_reports_outdated_when_no_window_matches_well`, `project_from_context_reports_file_deleted`, `project_from_context_of_a_whole_file_anchor_survives_any_edit`
-|`anchor.reachability` |`git-comment/src/lib.rs` (`StoredComment`, `embed`, `store`); `git-store/src/merge.rs` (`RawTree` handling in `classify`) |`git-comment::the_anchored_blob_survives_branch_deletion_and_gc_pruning_the_anchor_commit`
-|`web.render-registry` |`git-ents-server/src/render.rs` (`to_html`/`to_text`, `mime_for_name`) | _(passthrough fallback exercised by web/CLI render paths)_
-|`web.server-rendered` |`git-ents-server/src/web/*.rs` (Askama/maud templates, no client JS required) | _(manual UI verification)_
-|`web.index` |`git-ents-server/src/web/mod.rs` (`index`) | _(manual UI verification)_
-|`web.tabs` |`git-ents-server/src/web/pages.rs`, `templates/issues.html` | _(manual UI verification)_
-|`web.syntax-highlight` |`git-ents-server/src/web/git.rs` (`capped_read`, 2 MiB cap); `pages.rs` (rendered/source toggle, `#L<n>` anchors) |`web::git::tests::capped_read_flags_oversized_output`
-|`web.comments` |`git-ents-server/src/web/pages.rs` (comment listing/form); `write.rs` (`add_comment`) | _(manual UI verification)_
-|`web.auth.challenge` |`git-ents-server/src/web/write.rs` (`issue_challenge`, `take_challenge`) |`write::tests::a_consumed_challenge_does_not_verify_twice`
-|`web.auth.session` |`git-ents-server/src/web/write.rs` (`Session`, `csrf_ok`, `logout`) |`web_edit::an_edit_without_a_valid_csrf_token_is_refused`
-|`web.auth.edit` |`git-ents-server/src/web/write.rs` (`edit_config`, `signed_edit`, `require_admin_registered`) |`web_edit::a_member_edits_settings_through_the_browser`, `a_self_attested_member_is_refused_a_settings_edit`
-|`web.auth.webauthn-onboarding` | _Planned — see the requirement's note_ | —
-|`nonfunctional.push-latency` |`git-effect/src/engine.rs` (`post_receive` returns after `enqueue`) | _(structural; see `checks.post-receive`)_
-|`nonfunctional.memory-cap` |`git-ents-server/src/web/git.rs` (`capped_read`, `capped_read_bytes`) |`web::git::tests::capped_read_*`
-|`nonfunctional.concurrency` |`git-ents-server/src/http.rs` (`backend`, concurrent stdin/stdout); `git-effect/src/engine.rs` (`spawn_blocking`) |`server::responds_to_requests`
-|`nonfunctional.no-panic` |workspace-wide clippy lint configuration (`#![forbid(clippy::unwrap_used, ...)]`) |`cargo clippy --workspace --all-targets`
-|`nonfunctional.no-unsafe` |workspace-wide (`#![forbid(unsafe_code)]`) |`cargo clippy --workspace --all-targets`
-|`nonfunctional.object-store` |`git-store/src/lib.rs` (`Store::open`, common-dir odb) | _(see the module's own doc comment; exercised by hook integration tests)_
-|`compat.git` |`git-ents-server/src/http.rs`, `web/git.rs` (subprocess invocations) | _(implicit in all integration tests)_
-|`compat.ssh-keygen` |`git-signed-push/src/lib.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*`
-|`compat.openssh-signed-push` |`git-signed-push/src/lib.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key`
-|`compat.sprite` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`) | _(requires a live Sprite; not covered by unit tests)_
-|`compat.cgi` |`git-ents-server/src/http.rs` (`backend`, CGI env vars) |`server::push_then_clone_round_trip`
-|`compat.edition` |workspace `Cargo.toml` (`edition = "2024"`, `publish = false`) | _(build-time)_
-|`deploy.fly` |`.config/fly.toml` | _(deployment-time)_
-|`deploy.health` |`git-ents-server/src/main.rs` (`/healthz` route) | _(manual/liveness check)_
-|===
docs/spec/effect.adoc
@@ -1,0 +1,183 @@
+== The Effect
+
+An effect is a declarative, content-addressed subscription to a
+`CommitQuery` (<<query.grammar>>), whose execution is sandboxed and whose
+output re-enters the repository as verified data.
+It closes the loop the gate and `receive` (<<gate.tip-signed>>,
+<<receive.unit>>) open: every side effect, human-triggered or
+machine-triggered, is admitted, dispatched, and recorded through the same
+signed-commit channel.
+
+=== Definition
+
+[role="requirement", id="effect.definition"]
+.Effect Documents
+--
+An effect MUST be stored as repository data at `refs/meta/effects/<name>`,
+one ref per effect; the entity's stored shape — trigger, toolchains, run
+command — is specified in <<model.effect-definition>>, and its results
+land at a refname derived from `<name>` (<<effect.results-writeback>>),
+never at a stored pattern.
+`<name>` MUST be a valid ref-path segment; it is also the `effect-name`
+terminal of the query grammar (<<query.grammar>>).
+Decomposing effects one ref per name means each effect is added or removed
+as its own independently-signed push, and the admin-only rule
+(<<effect.admin-only>>) can be stated as a single refname glob rather than
+gating one shared ref.
+--
+
+[role="requirement", id="effect.admin-only"]
+.Effect Definitions Are Admin-Only
+--
+A push to `refs/meta/effects/*` MUST be rejected unless the pushing member
+is admin-registered, regardless of any other role rule `refs/meta/config`
+may define.
+Authoring an effect schedules code execution on canonical infrastructure,
+which needs more trust than an ordinary branch push, so this rule MUST be
+enforced explicitly and MUST be statable as the single refname glob
+`refs/meta/effects/*`.
+--
+
+[role="requirement", id="effect.validation"]
+.Write-Time Validation
+--
+Writing an effect definition MUST be rejected before it is stored when any
+name in its `toolchains` list is not a valid ref-path segment, or when its
+`trigger` fails to parse against the `CommitQuery` grammar
+(<<query.grammar>>) — including a `rev(expr)` naming a `refs/meta/*`
+pattern (<<query.rev>>) or a `meta(glob)` naming an effect-written namespace
+(<<query.meta>>).
+--
+
+=== Execution
+
+[role="requirement", id="effect.execution"]
+.Sandboxed Execution
+--
+A worker MUST execute an effect by dequeuing a matched `(effect, oid)`
+pair, materializing each of the effect's declared toolchains from
+`refs/meta/toolchains/<name>` (<<effect.toolchains>>), and running the
+effect's command inside a sandbox.
+Sandboxed execution MUST sit behind one `Executor` trait with multiple
+backends, selected only at a composition root (<<roots.local>>,
+<<roots.hosted>>), so no execution logic is duplicated per backend.
+Host-direct execution, with no sandbox, MUST require an explicit
+`--unsandboxed` flag and MUST be available only locally, never on canonical
+hosted infrastructure.
+--
+
+[role="requirement", id="effect.deployment-property"]
+.What To Run Is Data, How To Run Is Not
+--
+An effect's stored data MUST NOT be able to select its own executor, demand
+`--unsandboxed` (<<effect.execution>>), or set its own retry bounds.
+Executor choice and retry bounds MUST be deployment configuration, selected
+only at a composition root (<<roots.composition>>), never a field an effect
+definition can carry.
+--
+
+[role="requirement", id="effect.local-run"]
+.Local Execution Is a Correctness Anchor
+--
+`git effect run` MUST materialize toolchains and execute in a sandbox via
+the identical code path a hosted worker uses (<<effect.execution>>); only
+the durable queue MUST be skipped, and the queue MUST carry no correctness
+content this identity depends on.
+A member who runs `git effect run` locally MUST see the same outcome a
+canonical worker would record for the same commit.
+--
+
+=== Results
+
+[role="requirement", id="effect.results-writeback"]
+.Results Return Only Through Receive
+--
+An effect's results MUST return to the repository only as signed commits
+pushed through the ordinary `receive` path (<<receive.unit>>,
+<<receive.shared-path>>) onto its `results` ref; write-back MUST be an
+ordinary receive client, never a privileged write outside the gate.
+Results MUST land one ref per tested commit,
+`refs/meta/results/<effect>/<short-oid>`, derived from the effect's own
+name, so concurrent results for different commits, or from different
+runners, never contend the same ref.
+Bounding this namespace's growth — archival or rollup of old results — is
+deferred.
+--
+
+[role="requirement", id="effect.result-taxonomy"]
+.Result Taxonomy
+--
+A run's outcome MUST be recorded as exactly one of `pass`, `fail`, or
+`error`.
+A completed command's exit status MUST always be recorded as a result:
+`pass` or `fail` both mean the effect ran to completion.
+An infrastructure failure — the sandbox never started, the worker crashed —
+MUST NOT itself be recorded as a result; it MUST be treated as a queue
+concern retried within a bounded limit (<<effect.deployment-property>>),
+and a terminal `error` result — signed by the worker's own member key
+like any other result — MUST be written only when that bound is
+exhausted.
+A transient outage therefore neither retries forever nor permanently
+discharges the commit's obligation (<<query.workset>>).
+--
+
+=== Identity and Trust
+
+[role="requirement", id="effect.identity"]
+.The Runner Is a Member
+--
+A worker executing an effect MUST act as an ordinary member, never as an
+ambient authority: its result commit MUST be signed with its own member
+key.
+A result's signature proves only who pushed the result, not that the run it
+reports was faithfully executed.
+--
+
+[role="requirement", id="effect.official"]
+.Official Is a Refname Rule
+--
+A results ref MUST count as official only because the canonical
+`refs/meta/results/<effect>/*` namespace is writable solely by designated
+worker keys (<<gate.tip-signed>>) — a refname authorization rule, never a
+runtime property of the machine that produced the result.
+--
+
+[role="requirement", id="effect.self-run"]
+.Any Member May Self-Run
+--
+Any member MAY run any effect on their own executor and account.
+Such a member's results MUST land under the self-run namespace
+`refs/meta/results/~<member>/<effect>/<short-oid>` (<<meta-ref.inbox>>),
+never directly on the canonical results ref, and MUST be adoptable onto
+the canonical ref only through the same
+adoption merge as any other contribution (<<gate.adoption-merge>>,
+<<sync.adoption-machinery>>), with the trust decision explicit in that
+merge.
+--
+
+=== Toolchains
+
+[role="requirement", id="effect.toolchains"]
+.Hash-Pinned Toolchain Manifests
+--
+The stored shape of a toolchain — a hash-pinned manifest carrying its own
+provenance — is specified in <<model.toolchain>>; this requirement governs
+its bytes at run time.
+Only the sandbox (<<effect.execution>>) MAY touch a toolchain's extracted
+bytes; a manifest's declared components MUST be resolved during effect
+execution, never by any other code path.
+--
+
+=== Fanout Indexes
+
+[role="requirement", id="effect.fanout-index"]
+.Index Rebuild Is Itself an Effect
+--
+A fanout index at `refs/meta/index/*`, mapping object oids to the entities
+anchored to them, MUST be rebuilt only by an effect and written back only
+as a signed commit by the worker that ran it (<<effect.identity>>), never
+by any privileged out-of-band writer.
+A reader finding the index stale or absent MUST degrade to scanning ref
+tips directly and MUST NEVER derive a wrong answer from a stale or missing
+index — staleness MUST cost only performance, never correctness.
+--
docs/spec/gate.adoc
@@ -1,0 +1,166 @@
+== The Gate
+
+Verification is a pure function over ref-store reads: it MUST depend only on
+the read half of the ref store, never on write access or on any state
+outside `refs/meta/*`.
+The gate is the one admission judgment in the design; every mutation, human
+or machine, is admitted or refused by this same function (see
+<<receive.unit>>).
+
+=== Verification
+
+[role="requirement", id="gate.tip-signed"]
+.Tip Signature
+--
+The new tip of a meta-ref MUST be signed by a member authorized for that
+refname.
+This is checkable after the fact by anyone with a clone, not only by
+whoever ran the server.
+--
+
+[role="requirement", id="gate.refname-binding"]
+.Refname Binding
+--
+The commit's `Ents-Ref:` trailer MUST match the refname being updated.
+Without this check a signed commit could be replayed as the tip of a
+different meta-ref than the one its author signed for.
+--
+
+[role="requirement", id="gate.fast-forward"]
+.Fast-Forward Freshness
+--
+The new tip MUST descend from the old tip in the DAG sense: meta-refs
+advance fast-forward-only.
+The parent hash IS the anti-replay freshness binding; no nonce is needed.
+--
+
+[role="requirement", id="gate.atomic-cas"]
+.Atomic Update
+--
+The ref update MUST commit via atomic compare-and-swap against the old tip
+read in <<gate.fast-forward>>.
+--
+
+[role="requirement", id="gate.signature-artifact"]
+.Signature as Repository Data
+--
+A commit signature MUST be treated as a data artifact, not a transport
+artifact: it replicates with the repository and MUST verify offline in
+every clone, so verification evidence is itself repository state.
+A push certificate carries no meta-ref semantics; it MUST NOT be consulted
+by the gate.
+--
+
+[role="requirement", id="gate.policy-as-state"]
+.Policy Is Repository State
+--
+The member set and refname rules the gate consults MUST themselves live
+under `refs/meta/*`.
+Any frontend MUST therefore be able to evaluate the actual policy offline,
+with staleness bounded only by the age of its last fetch.
+--
+
+=== Verification Epoch
+
+[role="requirement", id="gate.epoch"]
+.Verification Epoch
+--
+The gate MUST apply the tip invariant (<<gate.tip-signed>> through
+<<gate.atomic-cas>>) only from an epoch recorded in `refs/meta/config`
+forward; history before the epoch is archival.
+The epoch-setting commit MUST be the first gated tip of the config ref
+itself, resolving the circularity of reading the epoch from the very ref
+the gate verifies.
+--
+
+=== Mandatory and Advisory Enforcement
+
+[role="requirement", id="gate.mandatory-hosted"]
+.Mandatory Enforcement, Hosted
+--
+The hosted store MUST run the gate at CAS time; a failing verdict MUST
+abort the transaction before any ref is updated.
+--
+
+[role="requirement", id="gate.advisory-local"]
+.Advisory Enforcement, Local
+--
+The local store MUST accept any write and run the gate only as a verdict
+annotating the result; a failing verdict MUST NOT block the write.
+Enforcing the gate locally would destroy offline-first: a user could not
+author while unenrolled, or work against an unfetched member list.
+--
+
+[role="requirement", id="gate.call-sites"]
+.One Function, Three Call Sites
+--
+The gate MUST be the same function at every call site: hosted CAS
+(<<gate.mandatory-hosted>>), the local UI verdict (<<gate.advisory-local>>),
+and push pre-flight (<<sync.pre-flight>>).
+A pre-flight verdict is a prediction that can only go stale; it MUST NOT
+diverge from the rules the hosted store will actually apply.
+--
+
+=== Adoption
+
+[role="requirement", id="gate.adoption-merge"]
+.Adoption Is a Merge
+--
+When the author of a commit and the member authorized to place it on the
+canonical ref differ, adoption MUST proceed by the authorized member
+merging the contributor's signed commit onto the canonical ref, even when
+the merge is trivial.
+The merge commit satisfies the tip invariant; the contributor's signed
+commit remains in ancestry with attribution intact.
+--
+
+[role="requirement", id="gate.adoption-no-cherry-pick"]
+.Cherry-Pick Forbidden as Adoption
+--
+Cherry-picking a contributor's commit MUST NOT be used as an adoption
+mechanism: it creates a new commit object and destroys the original
+author's signature.
+--
+
+[role="requirement", id="gate.adoption-no-fast-forward"]
+.Direct Fast-Forward Forbidden
+--
+Fast-forwarding a canonical ref directly to a contributor's commit MUST NOT
+be treated as adoption: it would place an unauthorized signature at the
+tip, since the contributor is not authorized for that refname.
+--
+
+[role="requirement", id="gate.same-actor-divergence"]
+.Same-Actor Divergence
+--
+Two of a member's own machines racing that member's single-writer ref MUST
+be resolved by merging the member's own divergent heads, not by erroring.
+Author and placer are both the same member, so the tip invariant holds as
+written once the merge tip descends from the old tip.
+--
+
+=== The Principled Split
+
+[role="requirement", id="gate.principled-split"]
+.Signatures Authorize Only Single-Writer Appends
+--
+A content signature MUST be treated as carrying write authorization only
+where mutations are author-signed single-writer appends, which the
+meta-ref granularity rule guarantees.
+On `refs/heads/*`, pushing commits the pusher did not author is legitimate,
+so branch refs MUST keep transport-level authorization instead of the tip
+invariant.
+--
+
+=== Bootstrap
+
+[role="requirement", id="gate.bootstrap"]
+.Empty Member List Bootstrap (Deferred)
+--
+When no `refs/meta/member/*` ref exists, the gate MUST treat the first
+enrollment as self-admitting so the first member can be pushed in.
+A member set that exists but whose keys are all revoked MUST fail
+closed: revoking every key MUST NOT reopen this self-admitting window.
+Closing "first push owns the repo" beyond this bootstrap admission is a
+hosted-deployment concern, deferred to <<roots.bootstrap>>.
+--
docs/spec/model.adoc
@@ -1,0 +1,124 @@
+== 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.
+
+'''''
+
+=== 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.
+
+[role="requirement", id="model.member-identity"]
+.Member Identity and Enrollment
+--
+A Member entity MUST carry the member's public key.
+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.
+--
+
+[role="requirement", id="model.member-revocation"]
+.Revocation Is a State, Not a Deletion
+--
+Revoking a member MUST record a revoked state on the member entity and
+MUST NOT delete the entity.
+A revoked key MUST be explicitly rejected for verifying any signature
+made after revocation, while a signature the key made before revocation
+MUST remain verifiable — deleting the entity instead would make old
+signatures unverifiable, the opposite of what an audit needs.
+Unrevoking a member MUST be supported, returning the key to authorizing
+new signatures without altering the record of the period it was revoked.
+--
+
+[role="requirement", id="model.member-provenance"]
+.Member Provenance and Trust Tier
+--
+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.
+--
+
+[role="requirement", id="model.member-worker"]
+.Machine Actors Are Members
+--
+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
+
+[role="requirement", id="model.comment"]
+.Comment Carries an Anchor
+--
+A Comment entity MUST carry a body and an anchor identifying the exact
+content it was written against (<<anchor.definition>>).
+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.
+Author and timestamp MUST come from the mutation commit chain, never from
+fields stored in the tree (<<meta-ref.trailers>>).
+Anchor resolution, projection onto other revisions, and reachability are
+specified in `anchor.adoc` and apply to a Comment's anchor unchanged.
+--
+
+=== Effect
+
+[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.
+Its results refname is derived from the effect's own name
+(<<effect.results-writeback>>), never stored as a field.
+It MUST NOT carry executor, sandbox, or retry fields; how an effect runs
+is a deployment property (<<effect.deployment-property>>).
+--
+
+=== Result
+
+[role="requirement", id="model.result-taxonomy"]
+.Result Status Taxonomy
+--
+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>>.
+--
+
+=== Toolchain
+
+[role="requirement", id="model.toolchain"]
+.Toolchain Manifest
+--
+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.
+--
+
+=== Account
+
+[role="requirement", id="model.account"]
+.Account Links Key to Login
+--
+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.
+--
docs/spec/query.adoc
@@ -1,0 +1,189 @@
+== The Commit Query
+
+A `CommitQuery` denotes a set of commits as a pure function of current ref
+state; every effect's `trigger` (<<effect.definition>>) is one.
+The algebra is deliberately tiny: three atoms — `rev()`, `results()`,
+`meta()` — closed under union, intersection, and difference.
+Composition happens by writing the query itself, never by a workflow
+language or a runtime scheduler.
+
+=== Grammar
+
+[role="requirement", id="query.grammar"]
+.Query Grammar
+--
+A `CommitQuery` MUST parse according to the following ASCII grammar:
+
+[source]
+----
+query ::= term (("|" | "&" | "-") term)*
+term ::= "(" query ")"
+ | "rev" "(" rev-expr ")"
+ | "results" "(" effect-name "," status ")"
+ | "meta" "(" ref-glob ")"
+status ::= "pass" | "fail" | "error" | "any"
+----
+
+`|` MUST denote union, `&` MUST denote intersection, and `-` MUST denote
+difference (<<query.set-ops>>).
+The binary operators MUST be left-associative and MUST share a single
+precedence level; parentheses are the only way to override left-to-right
+evaluation order.
+`rev-expr`, `effect-name`, and `ref-glob` are opaque terminal strings whose
+own syntax is defined by <<query.rev>>, <<effect.definition>>, and
+<<query.meta>> respectively, not by this grammar.
+--
+
+=== Semantics
+
+[role="requirement", id="query.rev"]
+.rev() Over Code Refs
+--
+`rev(expr)` MUST denote the commit set produced by evaluating `expr` as an
+ordinary Git revspec or ref glob against refs outside `refs/meta/*` —
+`refs/heads/*`, `refs/tags/*`, a range such as `main ^release`, an ancestry
+expression, or a merge-base expression.
+`refs/meta/*` MUST be outside `rev()`'s domain by definition: an `expr`
+naming a `refs/meta/*` pattern MUST be rejected as a malformed query
+(<<effect.validation>>), never silently evaluated to the empty set.
+--
+
+[role="requirement", id="query.results"]
+.results() Over Recorded Outcomes
+--
+`results(effect, status)` MUST denote the set of commits carrying a recorded
+result of `status` for the named `effect`, where `status` is `pass`, `fail`,
+`error`, or `any` (any recorded status).
+Resolving `results(effect, status)` MUST be cheap: because the results
+refname encodes the tested commit's oid (<<effect.results-writeback>>),
+resolution MUST be a scan of refname patterns under the effect's results
+namespace, never a walk of commit history.
+--
+
+[role="requirement", id="query.meta"]
+.meta() Over Author-Written Meta-Refs
+--
+`meta(glob)` MUST denote the set of tip commits of every author-written
+`refs/meta/*` ref whose name matches `glob`.
+`glob` MUST NOT be able to match an effect-written namespace —
+`refs/meta/results/*` or `refs/meta/index/*`.
+Recorded results are reachable only through `results(...)`
+(<<query.results>>); the fanout index (<<effect.fanout-index>>) MUST NOT
+be addressable by any query atom at all.
+--
+
+[role="requirement", id="query.set-ops"]
+.Union, Intersection, Difference
+--
+For any two sub-queries `L` and `R`, `L | R` MUST denote their union, `L &
+R` MUST denote their intersection, and `L - R` MUST denote the commits in
+`L` that are not in `R`.
+Parenthesizing a sub-expression MUST make it a single term for the purposes
+of <<query.grammar>>'s left-to-right evaluation.
+--
+
+=== Boundaries
+
+[role="requirement", id="query.no-extensions"]
+.No Content, Time, or External-Event Atoms
+--
+The grammar in <<query.grammar>> MUST NOT gain a content predicate — a term
+that inspects file or commit content beyond set membership and recorded
+result status; content awareness belongs inside an effect's `run` command,
+not its trigger.
+It MUST NOT gain a time atom — a term that inspects wall-clock time or a
+schedule.
+It MUST NOT gain an external-event atom — a term that inspects state
+outside the repository, such as a webhook payload.
+Both time and external events belong to whatever writes the ref a query
+then observes, never to the query language itself.
+--
+
+=== Evaluation
+
+[role="requirement", id="query.footprint"]
+.Static Ref-Footprint Extraction
+--
+The set of refname patterns a `CommitQuery` depends on MUST be extractable
+by static analysis of its syntax tree alone, without evaluating it against
+any ref state.
+A `rev(expr)` term MUST contribute `expr`'s own ref patterns; a
+`results(effect, status)` term MUST contribute the named effect's results
+refname pattern; a `meta(glob)` term MUST contribute `glob` itself.
+This is what lets a single ref transition be mapped to the set of affected
+queries (<<receive.event-sink>>) without re-scanning every effect on every
+push.
+--
+
+[role="requirement", id="query.incremental"]
+.Incremental Set Entry
+--
+A query's entry set for a ref transition `old..new` MUST be computed
+incrementally from that frontier, bounded by commit-graph generation
+numbers, and MUST NOT require re-evaluating the query against the full ref
+state.
+--
+
+[role="requirement", id="query.monotone"]
+.Monotone, Entry-Only Semantics
+--
+An effect MUST fire once per commit that enters its trigger's set.
+A ref update, including a force-push, MAY shrink the set a query denotes,
+but a commit leaving the set MUST NOT retract anything: results already
+written for that commit remain immutable history, and the commit MUST
+simply stop being treated as an outstanding obligation.
+This monotone, entry-only semantics MUST be what makes distributed
+evaluation safe with no coordination beyond the ref store's own
+compare-and-swap.
+--
+
+[role="requirement", id="query.workset"]
+.The Work Set Has No Pipeline State
+--
+The set of commits an effect still owes a result for MUST be computed as
+`trigger - results(self, any)`, where `self` is notation for the
+enclosing effect's own name, substituted at evaluation time — not a
+keyword an author may write in a trigger (<<query.grammar>>).
+The effect's own results ref MUST be the sole materialization marker for
+this computation; pipeline state MUST NOT exist anywhere outside the
+repository.
+Work-set evaluation MUST inherit the incremental bounds of
+<<query.incremental>> on its trigger side and the refname-scan resolution
+of <<query.results>> on its subtraction side, never a walk of full
+history.
+--
+
+[role="requirement", id="query.recursion"]
+.Recursion Is Structure, Not a Runtime Rule
+--
+An effect's trigger MAY name `results(...)` to react to another effect's
+results; whether a query is downstream of an effect MUST be determined by
+inspecting whether `results(...)` appears in its text, never by runtime
+behavior.
+Because `rev()` and `meta()` (<<query.rev>>, <<query.meta>>) MUST NOT be
+able to name an effect-written ref, a trigger cycle that an author did not
+write MUST be unreachable by construction, not merely discouraged.
+--
+
+=== Compatibility
+
+[role="requirement", id="query.rev-pattern-compat"]
+.Bare Glob Is the Degenerate rev() Query
+--
+A bare ref glob (for example `refs/heads/*`) MUST be accepted wherever a
+`CommitQuery` is expected, meaning exactly `rev(<glob>)`, so a `RefPattern`
+predating `CommitQuery` continues to denote the same commit set.
+--
+
+=== Composition idioms (non-normative)
+
+These are consequences of the grammar and semantics above, not additional
+rules:
+
+* Staged pipeline — integration tests only after unit tests pass:
+ `rev(refs/heads/main) & results(unit, pass)`.
+* Fan-in — an effect that requires several prerequisites: intersect their
+ `results(...)` queries; the effect fires when the last one lands,
+ regardless of the order the underlying refs moved.
+* Exclusion — CI that skips work-in-progress branches:
+ `rev(refs/heads/*) - rev(refs/heads/wip/*)`.
docs/spec/receive.adoc
@@ -1,0 +1,101 @@
+== Receive
+
+"Push" conflates object transfer with a verified ref transaction; locally,
+transfer is vacuous.
+The library exposes exactly one unit for mutation: `receive(refs, objects,
+events, proposal)`.
+Gate evaluation, effect matching, and enqueue live inside `receive`, above
+the `RefStore`, object-store, and `EventSink` traits it is handed.
+
+[role="requirement", id="receive.unit"]
+.Receive Is the Unit of Mutation
+--
+`receive(refs, objects, events, proposal)` MUST be the sole entry point
+through which a meta-ref or branch ref is mutated.
+Gate evaluation (<<gate.tip-signed>> through <<gate.atomic-cas>>), effect
+matching, and enqueue MUST live inside `receive`, above its trait
+parameters, never duplicated in a caller.
+--
+
+[role="requirement", id="receive.shared-path"]
+.One Receive, Every Frontend
+--
+Every mutation frontend MUST share the identical `receive` implementation
+with the hosted server; only the trait implementations passed to it may
+differ.
+The CLI and the local UI MUST call `receive` in-process against the local
+object database.
+Smart-HTTP MUST unpack the incoming wire pack into the `ObjectStore` before
+calling `receive`.
+Local and hosted MUST NOT diverge into separate push paths; they share
+`receive`, which is the correctness anchor for writes.
+--
+
+[role="requirement", id="receive.refstore-seam"]
+.RefStore Seam
+--
+The `RefStore` seam MUST expose reads and atomic multi-ref
+compare-and-swap, with reads split into their own trait so a read-only
+caller (<<gate.tip-signed>>) cannot depend on write access.
+--
+
+[role="requirement", id="receive.loose-cas-discipline"]
+.Loose Ref CAS Discipline
+--
+A loose-ref `RefStore` implementation MUST write refs through its own
+compare-and-swap discipline and MUST NOT shell out to `git update-ref`,
+so local mutations honor the same CAS guarantee hosted storage provides.
+--
+
+[role="requirement", id="receive.object-access"]
+.Object Access Via gitoxide
+--
+Object access inside `receive` MUST use `gix_object::Find`, `Exists`, and
+`Write`; a private object-access trait MUST NOT be introduced where
+gitoxide already has one.
+A `receive` running inside a git hook MUST read existing repository state
+through the common object directory, never through the quarantine
+directory git injects via `GIT_OBJECT_DIRECTORY` during a push:
+quarantined incoming objects are the proposal under verification, not
+durable state, and MUST NOT be treated as stored until the transaction
+commits.
+--
+
+[role="requirement", id="receive.event-sink"]
+.EventSink Payload Contract
+--
+`EventSink` MUST be the sole destination for post-receive matches: null
+locally, a durable queue hosted.
+`receive` MUST statically extract each effect query's ref footprint so a
+ref transition maps to the affected queries, and MUST enqueue
+re-evaluation for each match.
+--
+
+[role="requirement", id="receive.never-blocks"]
+.Pushes Are Never Blocked by Effects
+--
+`receive` MUST NOT block a push on effect evaluation; the durable enqueue
+in <<receive.event-sink>> MUST be the entire synchronous cost.
+Post-receive MUST remain a dumb matcher with no evaluation logic of its
+own.
+--
+
+[role="requirement", id="receive.dedup"]
+.Exactly-Once Outcomes
+--
+The queue fed by `receive` is at-least-once; the dedup key MUST be
+`(effect, oid)`, so that redelivery yields exactly-once outcomes with zero
+state kept outside the repository.
+--
+
+[role="requirement", id="receive.redaction-ingest"]
+.Redaction Enforcement at Ingest
+--
+`receive` MUST check every incoming object against the redaction list
+recorded under `refs/meta/redactions/*` at ingest time, so a redacted hole
+cannot be silently refilled by re-pushing the same bytes.
+A redacted object's bytes MUST be withheld from the object store and from
+every generated pack; the oid MUST remain in history as evidence.
+A reader resolving a redacted object MUST receive a redaction marker,
+never an error.
+--
docs/spec/roots.adoc
@@ -1,0 +1,129 @@
+== Composition Roots
+
+No code inside the library knows where it is running; the core is handed
+trait objects and never asks.
+Deployment exists only in composition roots: each wires the four seams —
+`RefStore`, an object store via gitoxide traits, `EventSink`, and
+`Executor` — in roughly fifty lines of trait construction.
+An `if hosted` branch inside the library is the design failing.
+
+[role="requirement", id="roots.composition"]
+.Deployment Lives Only at the Root
+--
+The four seams (`RefStore`, the object store via `gix_object::Find` /
+`Exists` / `Write`, `EventSink`, `Executor`) MUST be wired together only in
+a composition root.
+Library code outside a root MUST NOT select among implementations of
+these seams.
+--
+
+[role="requirement", id="roots.local"]
+.git-ents Local Root
+--
+The `git-ents` CLI's composition root MUST wire: a loose-ref `RefStore`,
+the local odb as object store, a Docker `Executor`, a null `EventSink`, and
+the advisory gate (<<gate.advisory-local>>).
+`git ents serve` MUST reuse this same wiring, adding only the smart-HTTP
+frontend on loopback.
+Local effect execution MUST be pull, via `git effect run`, never a daemon
+watching refs; the queue is the only component this root skips
+(<<effect.local-run>>).
+--
+
+[role="requirement", id="roots.hosted"]
+.git-ents-server Hosted Root
+--
+The `git-ents-server` composition root MUST wire: a Postgres `RefStore`,
+Tigris as object store, a durable queue as `EventSink`, a Fly.io Sprite
+`Executor`, and the mandatory gate (<<gate.mandatory-hosted>>).
+The worker (queue consumer, executor, push-back client) MUST stay a mode
+inside the `git-ents-server` binary until scale forces a split, and MUST
+NOT share in-process state with `receive` (<<receive.unit>>).
+--
+
+[role="requirement", id="roots.honesty-test"]
+.Single-Node Honesty-Test Root (Deferred)
+--
+A third composition root — SQLite, local filesystem, and Docker — MUST be
+constructible without modifying library code, as the project's honesty
+test for the seam design.
+--
+
+[role="requirement", id="roots.config-isolation"]
+.Config Selects, Never Leaks
+--
+Configuration MUST select trait implementations only at the composition
+root and MUST NOT leak past it; core code reading configuration directly
+is a missing trait, not a shortcut to take.
+--
+
+[role="requirement", id="roots.web-signing"]
+.Web Edit Signing Indirection
+--
+A hosted web edit MUST be signed by the server's own member key, and that
+key MUST itself be an enrolled member.
+The local web UI MUST sign edits with the user's own member key instead;
+the server-key indirection used hosted MUST NOT be imported into the local
+root.
+--
+
+[role="requirement", id="roots.worktree-update"]
+.Local Serve and the Working Tree
+--
+The `git-ents` local root MUST set `receive.denyCurrentBranch=updateInstead`
+so an accepted branch push also updates the working tree.
+Worktree update MUST happen only after `receive` accepts the push, as
+frontend business; core code MUST NOT touch a worktree.
+A dirty worktree is a known edge where `updateInstead` fails, so
+branch-push behavior differs from hosted while metadata behavior, which
+never touches a worktree, stays identical.
+--
+
+[role="requirement", id="roots.web-session"]
+.Hosted Web Sessions
+--
+A hosted web session MUST be held only in server memory, consistent with
+<<model.account>>'s ban on a session database.
+Every state-changing web request MUST carry a per-session CSRF token the
+server verifies before acting; a web edit MUST be signed
+(<<roots.web-signing>>) only on behalf of an authenticated session.
+--
+
+[role="requirement", id="roots.path-validation"]
+.Repository Path Validation
+--
+The hosted root MUST validate every repository-path segment before any
+filesystem or subprocess use, rejecting a path that would escape the
+data directory, nest inside an existing repository, or collide with a
+non-repository namespace directory.
+--
+
+[role="requirement", id="roots.fetch-auth"]
+.Fetch Authorization
+--
+Fetch authorization MUST be refname-keyed, using the same authorization
+model as write authorization.
+Private-repository access beyond refname-keyed fetch authorization is out
+of scope for v1.
+--
+
+[role="requirement", id="roots.bootstrap"]
+.Repository Creation on First Push (Deferred)
+--
+A hosted repository is created on its first `info/refs` request, before
+any signed push exists, which leaves "first push owns the repo" open per
+repository (<<gate.bootstrap>>).
+Closing it MUST require a server-level key-to-account registry gating
+repository creation; this direction is chosen but not yet enforced.
+--
+
+[role="requirement", id="roots.embeddable"]
+.Embeddable Server
+--
+`git-ents-server` MUST be a library first: the `serve` command, the
+standalone binary, and the git hooks MUST be thin wrappers over that
+library, not independent implementations.
+This MUST keep authorization (<<gate.tip-signed>>) and effect-matching
+(<<receive.event-sink>>) logic inside the library, never smeared across a
+subprocess boundary.
+--
docs/spec/server.adoc
@@ -1,214 +1,0 @@
-== The Server
-
-`git-ents-server` is a library first: the standalone binary and
-`git ents server` are the same code, and the git hooks are its subcommands.
-Anyone who can run the CLI can run the forge.
-
-[role="requirement", id="server.embeddable"]
-.Embeddable Server
---
-The server MUST be usable as a library: `git ents server` and the
-standalone `git-ents-server` binary MUST run the same code, and the
-`pre-receive` / `post-receive` hooks MUST be subcommands of the server, not
-separate programs.
---
-
-[role="requirement", id="protocol.git"]
-.Git Remote Compatibility
---
-The server MUST function as a full Git remote, supporting clone, fetch, and
-push via the standard Git smart-HTTP pack protocol without any special
-client configuration.
-The implementation MUST delegate the git wire protocol to
-`git http-backend` running as a CGI subprocess.
---
-
-[role="requirement", id="protocol.routing"]
-.Request Routing
---
-A single HTTP listener MUST serve both the git wire protocol and the
-browser web UI on the same port, routed by path and query:
-
-* Smart-HTTP service requests (`/info/refs?service=...`,
- `/git-upload-pack`, `/git-receive-pack`) and dumb-HTTP object paths
- (`/objects/...`) MUST be forwarded to `git http-backend`; so MUST POST
- requests to the smart-HTTP RPC paths.
-* Browser tree/blob/commit browse paths (`/tree/`, `/blob/`, `/commit/`)
- MUST be served by the web UI, even when a file path within them
- resembles a dumb-HTTP git path (a file named `HEAD`, a directory named
- `objects`).
-* Reserved top-level paths (`/login`, `/_debug`, `/healthz`) are served by
- the web UI and shadow a repository of the same name.
-* All other requests MUST be served by the web UI.
---
-
-=== Namespace
-
-[role="requirement", id="namespace.url"]
-.Service URL
---
-The service URL MUST be `git-ents.sh/git-ents`.
---
-
-[role="requirement", id="namespace.path"]
-.Repository Path Validation
---
-A repository path MUST consist of one to three segments, each drawn from
-ASCII alphanumerics plus `.`, `_`, and `-`; no segment may be empty, begin
-with `.`, or contain a path separator.
-A path that would escape the data directory, nest inside an existing
-repository, or collide with a namespace directory that is not a bare
-repository MUST be rejected before `git http-backend` is invoked.
---
-
-[role="requirement", id="namespace.auto-create"]
-.Automatic Repository Creation
---
-Pushing to a previously unused name MUST create a new bare repository,
-initialized with `http.receivepack = true`.
-Two concurrent first pushes to the same name MUST NOT both initialize the
-same repository: creation MUST be serialized behind a per-server lock.
-After a successful push, if `HEAD` points at a branch that does not exist,
-the server MUST update `HEAD` to the pushed branch — preferring `main`,
-then `master`, then the first available — so a fresh clone always checks
-out content.
---
-
-=== Compatibility
-
-[role="requirement", id="compat.git"]
-.Git Tooling
---
-The server MUST invoke `git` (including `git http-backend`,
-`git receive-pack`, `git archive`, `git cat-file`, `git for-each-ref`,
-`git symbolic-ref`, and `git init --bare`) as external subprocesses; git
-MUST be present on `PATH` at runtime.
-Git config overrides MUST be passed via `GIT_CONFIG_COUNT` /
-`GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` rather than `git -c`, so they
-reach the `receive-pack` and `pre-receive` processes that
-`git http-backend` spawns, not merely the CGI wrapper process.
---
-
-[role="requirement", id="compat.ssh-keygen"]
-.OpenSSH `ssh-keygen`
---
-Push certificate verification and browser sign-in verification MUST use
-`ssh-keygen -Y verify` (OpenSSH on `PATH` at runtime) against an
-`allowed_signers` file written at runtime, in the OpenSSH format: one line
-per key, with a principal column, optional comma-joined options
-(`cert-authority`, `valid-after`, `valid-before`, `namespaces`), and the
-key material.
---
-
-[role="requirement", id="compat.openssh-signed-push"]
-.Signed Push Protocol
---
-Push signatures MUST use OpenSSH (`gpg.format = ssh`) push certificates.
-The server MUST read the pushed certificate's object ID from
-`GIT_PUSH_CERT` and its nonce status from `GIT_PUSH_CERT_NONCE_STATUS` —
-the environment variables git populates for the `pre-receive` hook — and
-MUST NOT assume any other delivery mechanism.
---
-
-[role="requirement", id="compat.sprite"]
-.Sprite CLI
---
-Asynchronous checks MUST be executed via the `sprite` CLI (`sprite auth
-setup`, `sprite create`, `sprite exec`), present on `PATH` at runtime when
-checks are configured.
-The CLI MUST be initialized per-push via `sprite auth setup --token` from
-the `SPRITES_TOKEN` environment variable, since it persists credentials to
-a config file rather than reading the token per invocation.
---
-
-[role="requirement", id="compat.cgi"]
-.CGI Protocol
---
-`git http-backend` is a CGI program.
-The server MUST populate the standard CGI environment variables
-(`PATH_INFO`, `QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE`,
-`CONTENT_LENGTH`, `GIT_PROJECT_ROOT`, `GIT_HTTP_EXPORT_ALL`) before
-spawning it, and MUST parse the CGI response format (header block,
-`\r\n\r\n` separator, body) into an HTTP response.
---
-
-[role="requirement", id="compat.edition"]
-.Rust Edition
---
-All crates MUST target Rust edition 2024.
-The workspace MUST NOT be published to crates.io (`publish = false`).
---
-
-=== Deployment
-
-[role="requirement", id="deploy.fly"]
-.Fly.io Deployment
---
-The server MUST be deployable to Fly.io.
-Fly configuration MUST live at `.config/fly.toml` and all `fly`/`flyctl`
-invocations MUST pass `-c .config/fly.toml`.
---
-
-[role="requirement", id="deploy.health"]
-.Health Check
---
-The server MUST expose a liveness probe at `GET /healthz` returning `200`
-with body `ok` without touching any git repository, so the platform can
-route traffic before any repository exists.
-`GET /` itself is the web UI's repository index (<<web.index>>), not the
-probe.
---
-
-=== Nonfunctional
-
-[role="requirement", id="nonfunctional.push-latency"]
-.Push Latency
---
-A push MUST NOT block on check execution: the `post-receive` hook MUST
-return as soon as job files are written to the queue.
---
-
-[role="requirement", id="nonfunctional.memory-cap"]
-.Per-Request Memory Cap
---
-No single web request MAY read more than 2 MiB of git object data into
-memory for rendering.
-Output exceeding the limit MUST be truncated with a notice, not an error.
---
-
-[role="requirement", id="nonfunctional.concurrency"]
-.Concurrency Model
---
-The HTTP server MUST handle concurrent requests without blocking the async
-runtime on synchronous work; check jobs MUST run on blocking threads.
-The request body MUST be written to `git http-backend`'s stdin concurrently
-with draining its stdout — a sequential write-then-read deadlocks when
-`receive-pack` streams progress before consuming the full pack.
---
-
-[role="requirement", id="nonfunctional.no-panic"]
-.No Panics
---
-The implementation MUST NOT use `unwrap`, `expect`, unchecked
-indexing/slicing, or other constructs that can panic in production code
-paths.
-Any suppression of a panic-prevention lint MUST carry a documented reason
-(`#[expect(..., reason = "...")]`); silent `#[allow(...)]` is forbidden.
---
-
-[role="requirement", id="nonfunctional.no-unsafe"]
-.No Unsafe Code
---
-The implementation MUST NOT contain any `unsafe` code blocks.
---
-
-[role="requirement", id="nonfunctional.object-store"]
-.Durable Object Store Reads
---
-Meta-ref documents MUST be read and written against the repository's common
-object directory, never a receive-pack quarantine: inside a `pre-receive`
-or `post-receive` hook, git points the per-object-path environment
-variables at a quarantine holding only the incoming pack.
-All meta-ref access MUST open the object database at the common directory
-explicitly.
---
docs/spec/signed-push.adoc
@@ -1,124 +1,0 @@
-== The Signed Push
-
-There is exactly one way to mutate a repository, including its metadata: a
-`git push` carrying a signed-push certificate, verified against the member
-refs.
-The web UI has no side door — a browser edit becomes a real push signed by
-the server's own key.
-Auth state is repository state: no sessions database, no tokens table;
-revocation is a ref update.
-
-[role="requirement", id="auth.bootstrap"]
-.Bootstrap Window
---
-When no `refs/meta/member/*` refs exist the repository MUST accept any push
-without a signature, so the first member can be pushed in.
-Once at least one member ref exists the bootstrap window MUST close and all
-subsequent pushes MUST be signed.
-Revoking every member's keys — leaving the member refs present but all keys
-removed — MUST fail closed, not reopen the bootstrap window.
---
-
-[role="requirement", id="auth.signed-push"]
-.Signed Push Verification
---
-Every push to a repository whose member list is non-empty MUST carry a
-signed push certificate (`git push --signed`).
-The server MUST verify the certificate in a `pre-receive` hook before any
-ref is updated:
-
-. The push certificate nonce status (from `GIT_PUSH_CERT_NONCE_STATUS`)
- MUST be `OK`.
-. The certificate MUST contain an SSH signature (an
- `-----BEGIN SSH SIGNATURE-----` block).
-. The signature MUST verify against at least one authorized member key via
- `ssh-keygen -Y verify -n git`.
-. The trust set fed to `ssh-keygen` MUST be the live member set minus any
- revoked fingerprints.
-
-A push that fails any check MUST be rejected before any ref is updated.
---
-
-[role="requirement", id="auth.nonce"]
-.Nonce Configuration
---
-The server MUST configure `receive.certNonceSeed` on every
-`git http-backend` invocation when authentication is active, and
-`receive.certNonceSlop = 60` to tolerate the round-trip latency inherent in
-smart-HTTP, where the nonce is issued and verified by two separate
-`receive-pack` processes.
---
-
-[role="requirement", id="auth.client-setup"]
-.Client-Side Setup
---
-A CLI command (`git ents members setup`) MUST configure the client's local
-or global git config to sign pushes with an SSH key, using the key named by
-`user.signingkey` or a default `~/.ssh/id_ed25519`.
---
-
-=== Web Sign-In
-
-[role="requirement", id="web.auth.challenge"]
-.Challenge–Response Sign-In
---
-Browser sign-in MUST NOT require a private key to be transmitted.
-The server MUST issue a one-time nonce (challenge); the member MUST sign it
-locally with their web key using SSHSIG under the `git.ents.cloud`
-namespace (distinct from the git push namespace) and paste back the public
-key and signature.
-The server MUST verify the signature against the pasted key, and the key
-against the live member list for the repository being edited, before
-opening a session.
-A challenge MUST expire after 600 seconds and MUST be consumed on first use
-so it cannot be replayed.
-The sign-in page MUST point at `git ents login` (<<cli.login>>) as the
-preferred flow, keeping the manual signing instructions as the fallback.
---
-
-[role="requirement", id="web.auth.session"]
-.Sessions and CSRF
---
-A successful sign-in MUST issue a session cookie (`ents_session`) containing
-an unguessable random token.
-Each session MUST carry a separate CSRF token that state-changing POST
-requests MUST echo back.
-Sessions MUST be held only in server memory, storing only the member's
-public key and display label — no private key is ever held or transmitted,
-and no session state is persisted to disk.
-Signing out MUST drop the session from server memory and clear the cookie;
-the sign-out POST MUST itself carry the CSRF token.
---
-
-[role="requirement", id="web.auth.edit"]
-.Authenticated Settings Edit
---
-A settings edit MUST be landed as a real `git push --signed` onto
-`refs/meta/config`, signed with the server's own member key, through the
-same `pre-receive` gate a CLI push traverses.
-The commit's author MUST be the signed-in human (resolved from their
-session's public key to their member username); the committer MUST be the
-server identity.
-The edit MUST be staged on a throwaway ref, deleted whether or not the push
-succeeds.
-If the server has no signing key or nonce seed, the Settings tab MUST hide
-the edit controls rather than present a form that cannot succeed.
---
-
-[role="requirement", id="web.auth.webauthn-onboarding"]
-.Passkey Onboarding
---
-[NOTE]
-Planned, not yet implemented. `Trust::WebAuthn` and
-`Provenance::SelfAttestedWeb` exist and are exercised (a `WebAuthn` member
-never produces a push line; `require_admin_registered` refuses a
-self-attested member's edit), but no endpoint yet lets a new member create
-that ref by proving a passkey.
-
-A new member MAY onboard without a CLI by proving control of a passkey in
-the browser.
-The server MUST verify the attestation server-side and write the new member
-ref with `provenance` set to self-attested, recording the attestation
-evidence in the member ref's first commit as an audit record.
-The resulting member gets limited trust until an admin promotes them.
---
docs/spec/sync.adoc
@@ -1,0 +1,61 @@
+== Sync
+
+`git ents` adds exactly one capability over the local primitives: remote
+synchronization, which fetches the relevant `refs/meta/*` before acting.
+Sync moves the forge itself, not merely code, and it is the frontend that
+turns the gate's verdict (<<gate.call-sites>>) into a decision the user acts
+on before pushing.
+
+[role="requirement", id="sync.forge-transfer"]
+.Clone Moves the Whole Forge
+--
+Fetching or pushing meta-refs MUST move the entire forge, not a subset of
+convenience: a clone plus `refs/meta/*` MUST carry the complete audit
+history and the signatures needed to verify it, with no server-side data
+left behind.
+--
+
+[role="requirement", id="sync.pre-flight"]
+.Pre-Flight Uses the Same Gate
+--
+Push pre-flight MUST evaluate the identical gate function
+(<<gate.tip-signed>> through <<gate.atomic-cas>>) that the hosted store
+runs at CAS time (<<gate.mandatory-hosted>>).
+A pre-flight verdict MUST be reported as a prediction that can only be
+stale, never as one that can be wrong about the rules
+(<<gate.call-sites>>).
+--
+
+[role="requirement", id="sync.inbox-routing"]
+.Rejection Routes to the Inbox
+--
+When pre-flight predicts, or the canonical store actually returns, a
+rejection of a push to a canonical meta-ref, sync MUST offer to route the
+same commit to the author's `refs/meta/inbox/*` ref instead of discarding
+it.
+--
+
+[role="requirement", id="sync.divergence-merge"]
+.Schema-Aware Divergence Merge
+--
+Sync MUST resolve same-actor divergence (<<gate.same-actor-divergence>>) by
+a schema-aware three-way merge over the typed tree, never a textual merge.
+The resulting merge tip MUST satisfy the tip invariant.
+--
+
+[role="requirement", id="sync.adoption-machinery"]
+.Adoption Rides the Same Merge Machinery
+--
+A maintainer merging an inbox entity onto its canonical ref, and a
+maintainer adopting a contributor's self-run results, MUST both go through
+the same merge machinery as <<sync.divergence-merge>> and
+<<gate.adoption-merge>>, not a separate adoption code path.
+--
+
+[role="requirement", id="sync.local-advisory"]
+.Local Store Accepts Regardless of Verdict
+--
+The local store MUST accept a write regardless of the gate's verdict; the
+gate's advisory role (<<gate.advisory-local>>) governs annotation only, and
+sync MUST NOT use a failing local verdict to block a local write.
+--
docs/spec/web.adoc
@@ -1,85 +1,0 @@
-== Web UI
-
-The browser UI is server-rendered HTML over the same state the CLI reads;
-documents render through one MIME-keyed registry shared by both.
-
-[role="requirement", id="web.render-registry"]
-.Rendering Registry
---
-Documents MUST be rendered by MIME type through a single registry shared by
-the web UI and the CLI, producing HTML for the browser and plain text for
-the terminal from the same source.
-An unregistered MIME type MUST fall through to passthrough rather than an
-error, since MIME is an open namespace.
---
-
-[role="requirement", id="web.server-rendered"]
-.Server-Rendered HTML
---
-All browser-facing pages MUST be rendered server-side with no required
-JavaScript on the client.
-Page navigation MUST be ordinary links; folder expansion and file viewing
-MUST be plain GET requests.
---
-
-[role="requirement", id="web.index"]
-.Repository Index
---
-The web root (`GET /`) MUST render an index of the repositories
-discoverable under the data directory, each linking to its overview page.
-When no repository exists yet, the index MUST show a blank-slate prompt
-explaining that a push creates one, rather than an error.
---
-
-[role="requirement", id="web.tabs"]
-.Repository Tabs
---
-Each repository's web UI MUST provide at minimum:
-
-Files::
- The file tree, browsable to arbitrary depth, with syntax-highlighted blob
- views and rendered AsciiDoc and Markdown.
-
-Commits::
- The commit history with diff views.
-
-Releases::
- Git tags treated as release milestones, browsable by version.
-
-Checks::
- The configured check set and every recorded run with per-check outcomes.
-
-Issues::
- The issue list with open/closed filter and per-issue detail view.
-
-Settings::
- The repository's `Config` fields (`description`, `homepage`, `topics`),
- editable in the browser by signed-in members.
---
-
-[role="requirement", id="web.syntax-highlight"]
-.Syntax Highlighting
---
-Blob views MUST syntax-highlight source files using a compile-time language
-registry; AsciiDoc and Markdown MUST be rendered to HTML.
-A blob rendered as a formatted document MUST offer a toggle between the
-rendered document and its highlighted source.
-Blob gutter line numbers MUST be self-linking anchors (`#L<n>`).
-Files larger than 2 MiB MUST be truncated (<<nonfunctional.memory-cap>>).
---
-
-[role="requirement", id="web.comments"]
-.Comments in the Browser
---
-A file's anchored comments MUST be listed under its blob view, projected
-onto the revision being viewed (<<comments.projection>>), linking their
-line range into the blob's gutter and flagged when the projection reports
-them outdated.
-A signed-in member MUST be able to add a comment from the file view; the
-comment anchors to the viewed tip and MUST land as a signed push through
-the same `pre-receive` gate a settings edit traverses (<<web.auth.edit>>),
-with the signed-in human as author.
-Commenting MUST NOT require admin-registered provenance: issues and
-comments are exactly the writable surface allowed to a self-attested
-member (<<members.provenance>>).
---