git-ents.gitmain
⌘K
foforge
commit b10af33
docs: 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

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

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