git-ents.gitmain
⌘K
foforge
commit 4243594
docs: restructure the spec around the load-bearing abstractions

One file per abstraction — meta-ref (with members, revocations, account, config, issues, comments as children), anchor, signed-push, checks, server (namespace, compat, deployment, nonfunctional), web, cli — prose tightened, all 68 requirement ids preserved and tracey-validated.

docs: add server.embeddable and web.render-registry requirements 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/checks.adoc @@ -1,112 +1,115 @@ -== Checks +== The Check + +A check is data (`refs/meta/checks`), 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 at `refs/meta/checks` as -a map from check name to a check body holding an optional shell command, an -optional sandbox image, an optional list of dependencies (names of sibling -checks that must pass first), and an optional list of toolchain names -(<<checks.toolchains>>). -A check with no command is a composite: it runs nothing itself and derives its -outcome from its dependencies alone. +The configured checks for a repository MUST be stored at `refs/meta/checks` +as a map from check name to a check body holding an optional shell command, +an optional sandbox image, an optional list of dependencies (names of +sibling checks that must pass first), and an optional list of toolchain +names (<<checks.toolchains>>). +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. 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 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, rather -than the image being silently ignored; the field is reserved in the format so -honoring it later is not a data migration. +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 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.toolchains"] .Check Toolchains -- A check's optional list of toolchain names MUST each be a valid ref-path -segment (<<storage.meta-ref>>'s collection-key rule), 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. +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. +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, so the push is never blocked on check execution. -Job files MUST be written to a tmp path and renamed into place so the worker -never observes a partial file. +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. -Separate repositories MUST be processed concurrently, so a slow repository's -backlog does not block others. +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. +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. +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"] .Sprite Sandbox -- -Each check MUST run inside a Fly.io Sprite: a persistent, hardware-isolated -sandbox, one per repository, so build caches survive between pushes. -Before running checks the worker MUST sync the pushed tree into the Sprite's -work directory via `git archive` piped to `tar -x`. -The worker MUST configure the `sprite` CLI from `SPRITES_TOKEN` before each -run via `sprite auth setup`, so the credential stays current without restart. +Each check MUST run inside a Fly.io Sprite: a persistent, +hardware-isolated sandbox, one per repository, so build caches survive +between pushes. +Before running checks the worker MUST sync the pushed tree into the +Sprite's work directory via `git archive` piped to `tar -x`, and 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` tree (`git-toolchain`'s `Toolchain::bin`, not the whole -document) and extract that tree into the Sprite at a hash-keyed directory, -skipping the extraction when that directory already exists — the Sprite's -persistent filesystem is the cache, so the same toolchain is never -re-extracted across pushes. A check's command MUST then run with each of its -named toolchains' extracted `bin` directory prefixed onto `PATH`, in the -order they are declared, so an earlier toolchain takes precedence over a -later one on a name collision. +its typed `bin` tree (`Toolchain::bin`, not the whole document) and extract +it into the Sprite at a hash-keyed directory, skipping the extraction when +that directory already exists — the Sprite's persistent filesystem is the +cache. +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/runs/<commit>`, one ref per checked -commit. -Each ref's commit chain is the run history; each commit's date is the run time, -so no timestamp is duplicated in the document tree. +Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per +checked commit. +Each ref's commit chain is the run history; 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` (a check whose dependency did not pass, per <<checks.worker>>). -A run that cannot complete due to an infrastructure error (unreachable Sprite, -failed sync) MUST be finalized as `error` rather than left stuck at `running`. -A single check that exceeds a 30-minute timeout MUST be recorded `error` rather -than blocking the worker thread indefinitely. -A check definition and a run outcome MAY each carry additional metadata beyond -a single command/outcome string (for example a run's duration and log URL). -Optional fields absent from an older record MUST load as unset. -A check/outcome name is the map key and MUST NOT be stored redundantly inside -the record. +`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 check/outcome name is the map key and MUST NOT be stored redundantly +inside the record. -- [role="requirement", id="checks.debug"] @@ -116,9 +119,8 @@ a repository's checks Sprite (`git ents checks 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. -The broker MUST refuse the connection without a signed-in session. +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, so the remote shell tracks the -member's window size. +control frames distinct from the byte stream. --
docs/spec/cli.adoc @@ -1,15 +1,18 @@ == 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`) into the local clone, 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 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`, `checks list`, `comment list`/`show`) MUST only fetch, never push. -- @@ -19,18 +22,19 @@ -- 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. +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. -This is the client-side counterpart to <<storage.concurrency>>. +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"] @@ -38,19 +42,21 @@ -- 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; adding a member MUST - create the ref when absent. +* `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`. +* `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 (see <<auth.client-setup>>). + 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 -before proceeding, since it can lock the operator out of the remote. +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"] @@ -70,28 +76,26 @@ * `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 and push it, creating the ref - when absent. `bin` MUST be non-empty; `license` MUST be a valid SPDX - license expression; `version` MUST be a valid semver version; `platform` - MUST be a valid target triple. -* `import --from <recipe>` — derive `bin`/`src`/`license`/`version`/ - `platform` from a local toolchain install instead of supplying them by - hand, via a named recipe (currently only `rustup`, selected further by - `--spec <name>`, e.g. `stable`); any of the fields also passed explicitly - override the recipe's value. By default 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` instead of importing - local bytes, sparing the repository the toolchain's own bytes; `--embed` - forces the recipe to import the local install's actual `bin` bytes - instead, as `import` without `--from` always does. -* `list` — render every toolchain configured on a remote with its `bin` - (a tree id, or a component count when hosted externally), version, - platform, and license. -* `export` — recreate a remote's named toolchain's `bin` (and `src`, if - present) under a local destination directory, restoring the executable bit - and symlinks — fetching and sha256-verifying `bin`'s components first when - hosted externally rather than embedded — and print the version, platform, - and license; read-only, per <<cli.remote-admin>>. + `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 via a named recipe (currently only `rustup`, selected further by + `--spec <name>`, e.g. `stable`); 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. +* `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 and sha256-verifying externally hosted components first — and + print the version, platform, and license; read-only, per + <<cli.remote-admin>>. * `remove` — delete the toolchain's ref on a remote. -- @@ -103,10 +107,10 @@ * `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>>). +* `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 @@ -117,10 +121,9 @@ .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: the CLI -fetches a challenge from the remote, signs it with the client's configured -signing key, submits the proof, and receives the same session a browser -sign-in yields. +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. -- @@ -128,11 +131,11 @@ [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 +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. +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 @@ -13,6 +13,7 @@ |`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)_ @@ -53,6 +54,7 @@ |`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`) |`git-anchor::capture_records_the_commit_and_blob_and_snippet_derives_the_text`, `capture_rejects_a_missing_path_and_an_oversized_range` |`comments.projection` |`git-anchor/src/lib.rs` (`project`, `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` +|`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)_
docs/spec/overview.adoc @@ -4,10 +4,25 @@ [abstract] `git-ents` is a Git forge: a self-hosted, membership-gated Git server with a browser UI, a CLI, asynchronous CI checks, an issue tracker, and anchored -code comments, all stored as typed documents on git meta-refs. -Every piece of state — members, configuration, checks, runs, issues, comments -— lives in the repository itself, versioned and auditable, with no external -database. +code comments. +Every piece of state — members, configuration, checks, runs, issues, +comments — lives in the repository itself as typed documents on git +meta-refs, versioned and auditable, with no external database. + +The spec is organized around the load-bearing abstractions (rationale in +`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). +* `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. [CAUTION] This specification is not yet stable and will grow as the project develops.
docs/spec/web.adoc @@ -1,19 +1,32 @@ == 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 to the server. +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. +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. -- @@ -21,11 +34,11 @@ [role="requirement", id="web.tabs"] .Repository Tabs -- -Each repository's web UI MUST provide at minimum the following tabs: +Each repository's web UI MUST provide at minimum: Files:: - The repository's file tree, browsable to arbitrary depth, with syntax- - highlighted blob views and rendered AsciiDoc and Markdown 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. @@ -48,27 +61,25 @@ .Syntax Highlighting -- Blob views MUST syntax-highlight source files using a compile-time language -registry. -AsciiDoc and Markdown files MUST be rendered to HTML. +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 syntax-highlighted source. +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 rather than loaded in full, to bound -memory cost per request. +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. +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>>). +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>>). --
docs/spec/comments.adoc → docs/spec/anchor.adoc @@ -1,27 +1,9 @@ -== Comments +== The Anchor -[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: the object -id of the originating object, or of the comment's own initial content, and -it 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. --- - -[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. --- +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. [role="requirement", id="comments.anchor"] .Anchors
docs/spec/account.adoc @@ -1,23 +1,0 @@ -== 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. ---
docs/spec/auth.adoc @@ -1,50 +1,0 @@ -== Push Authentication - -[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. -The server MUST configure `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`. ---
docs/spec/compat.adoc @@ -1,65 +1,0 @@ -== 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` environment variables 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` against an `allowed_signers` file written at runtime. -`ssh-keygen` (OpenSSH) MUST be present on `PATH` at runtime. -The `allowed_signers` format used MUST be 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`). -The `sprite` binary MUST be present on `PATH` at runtime when checks are -configured. -The `sprite` CLI MUST be initialized per-push via `sprite auth setup --token` -from the `SPRITES_TOKEN` environment variable, since the CLI persists -credentials to a config file and does not read 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. -The server MUST parse the CGI response format (header block, `\r\n\r\n` -separator, body) and translate it 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`). ---
docs/spec/config.adoc @@ -1,11 +1,0 @@ -== Repository Configuration - -[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`. -Keeping metadata on a meta ref means a push of ordinary content cannot rewrite -it, and the metadata carries its own independent history. -An absent ref MUST yield the default (all empty / zero). ---
docs/spec/deployment.adoc @@ -1,19 +1,0 @@ -== 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` that returns `200` -with body `ok` without touching any git repository, so the platform can route -traffic before any repository exists. -`GET /` itself is served by the web UI as the repository index -(<<web.index>>), not the probe. ---
docs/spec/issues.adoc @@ -1,27 +1,0 @@ -== 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 -described 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. -This identifier is conflict-free and requires no counter. -An issue MAY additionally carry a friendly sequential number, assigned only -when a maintainer promotes it; before promotion this number is absent. -Only promotion advances the number counter, so filing an issue never -contends it. -Any future cross-referencing feature (comments, reviews, and the like) MUST -key off the stable content-hash identifier, not the friendly number. ---
docs/spec/members.adoc @@ -1,79 +1,0 @@ -== Members - -[role="requirement", id="members.ref"] -.Member Refs --- -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. --- - -[role="requirement", id="members.trust"] -.Member Trust Modes --- -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. - The enterprise option: 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. --- - -[role="requirement", id="members.provenance"] -.Member Provenance --- -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 the allowed set — 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. --- - -[role="requirement", id="members.window"] -.Trust Window --- -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`. --- - -[role="requirement", id="members.allowed-signers"] -.Allowed Signers Rendering --- -The server MUST render the live member set as an OpenSSH `allowed_signers` file -for `ssh-keygen -Y verify`. -The principal column MUST be a wildcard (`*`) so any email matches. -Each member's validity window MUST be rendered as comma-joined options -(`valid-after`, `valid-before`). -Each leaf key MUST appear on its own line; a pinned CA MUST appear as a -`cert-authority` line. -All lines MUST carry `namespaces="git"`. ---
docs/spec/meta-ref.adoc @@ -1,0 +1,286 @@ +== 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. + +[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. + +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="storage.concurrency"] +.Concurrent Writes +-- +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. +-- + +=== 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`, `checks`, + `revoked`, `issue-number`): `Store::load`/`store`. + A *named collection* is one-ref-per-item under a namespace prefix + (`member/<username>`, `toolchains/<name>`) or a scalar-keyed map on a + single ref (`checks/<name>`, `revoked/<fingerprint>`, + `runs/<commit>/results/<name>`): `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 +-- +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. +-- + +[role="requirement", id="members.trust"] +.Member Trust Modes +-- +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. +-- + +[role="requirement", id="members.provenance"] +.Member Provenance +-- +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. +-- + +[role="requirement", id="members.window"] +.Trust Window +-- +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`. +-- + +[role="requirement", id="members.allowed-signers"] +.Allowed Signers Rendering +-- +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. +-- + +[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. +--
docs/spec/namespace.adoc @@ -1,31 +1,0 @@ -== 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 a -conservative character set: 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` so smart-HTTP pushes are accepted. -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 point at the pushed branch, preferring `main`, -then `master`, then the first available branch, so a fresh clone always checks -out content. ---
docs/spec/nonfunctional.adoc @@ -1,59 +1,0 @@ -== 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, so the client's `git push` connection is released before any check runs. --- - -[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. -Blobs, diffs, and other rendered output that exceed this limit MUST be -truncated and the UI MUST display a notice rather than 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 be run on blocking threads (off the async executor) so a -long-running check cannot starve unrelated HTTP handlers. -The request body MUST be written to `git http-backend`'s stdin concurrently -with draining its stdout; a sequential write-then-read would deadlock when -`receive-pack` streams progress to the client before it has consumed 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 be accompanied by a documented -reason (`#[expect(..., reason = "...")]`); silent `#[allow(...)]` attributes are -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 rather than any quarantine overlay. -Inside a `pre-receive` or `post-receive` hook, git points the per-object-path -environment variables at a receive-pack quarantine that holds only the incoming -pack; the durable store, where meta-refs live, is the common directory. -All meta-ref access MUST open the object database at the common directory -explicitly so it is not accidentally limited to the quarantine. ---
docs/spec/protocol.adoc @@ -1,34 +1,0 @@ -== Protocol - -[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 (`git-upload-pack` and -`git-receive-pack`) without any special client configuration. -The implementation MUST delegate the git wire protocol to `git http-backend` -running as a CGI subprocess, translating between HTTP requests and the CGI's -stdin/stdout. --- - -[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. -Requests are routed by inspecting the 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`. -* 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 (e.g. a file named `HEAD` or 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 GET requests MUST be served by the web UI. -* POST requests to the git smart-HTTP RPC paths - (`/git-upload-pack`, `/git-receive-pack`) MUST be forwarded to - `git http-backend`. - All other POST requests MUST be handled by the web UI. ---
docs/spec/revocations.adoc @@ -1,19 +1,0 @@ -== 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, so a -compromised key is refused the moment it is listed. --- - -[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 by a fingerprint, so the revocation list operates -only on leaf-key fingerprints. ---
docs/spec/server.adoc @@ -1,0 +1,214 @@ +== 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,0 +1,124 @@ +== 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/storage.adoc @@ -1,137 +1,0 @@ -== Storage - -[role="requirement", id="storage.bare"] -.Persistent Bare Repository --- -Repository state MUST be persisted in a bare Git repository on durable storage. -A bare repository is created automatically on the first push to any previously -unused name and is 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. - -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 to catch a regression at test time rather than in -production. --- - -[role="requirement", id="storage.concurrency"] -.Concurrent Writes --- -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 value 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. --- - -=== Invariants & Abstractions - -This section documents, rather than mandates, how `git-store` (the crate -implementing <<storage.meta-ref>> and <<storage.concurrency>>) makes the -storage layer's invariants explicit in code instead of leaving them to be -re-derived per module. It exists so the next module added to `git-ents` -reuses these instead of re-inventing them. - -Key strategy:: - Every meta-ref document falls into one of three shapes. A *singleton* lives - on one fixed ref (`config`, `account`, `checks`, `revoked`, - `issue-number`) and is read and written with `Store::load`/`store`. A - *named collection* is decomposed one-ref-per-item under a namespace prefix, - keyed by a caller-supplied name (`member/<username>`) or a scalar-keyed map - on a single ref (`checks/<name>`, `revoked/<fingerprint>`, - `runs/<commit>/results/<name>`) — `Store::load_item`/`store_item`, - `load_map`/`store_map`. A *content-addressed* collection is keyed by the - hash of its own content (`issues/<genesis-hash>`, - `comments/<genesis-hash>`) 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. `toolchains/<name>` - (`git-toolchain`) is a named collection like `member/<username>`, and its - item is a `Facet` document like any other — a `bin`, an optional `src` - directory tree, an SPDX license expression, a semver version, and a - target-triple platform. `src` is always captured whole as a - `facet_git_tree::RawTree`, a raw-passthrough field wrapping an - already-written tree's object id rather than a directory layout `Facet` - could model field-by-field. `bin` is a `Bin` enum over two - representations: `Bin::Embedded`, the same `RawTree` capture, for a - toolchain with no stable independent origin; or `Bin::Downloaded`, a list - of `{url, sha256}` components pointing at a distributor's own hosted, - hash-pinned archives (rust-lang's dist tarballs, for the `rustup` import - recipe's default) instead of storing the toolchain's bytes at all — - fetched, sha256-verified, and extracted fresh each time a Sprite activates - the toolchain or `git ents toolchain export` runs locally, sparing the - repository the bytes entirely. `license`, `version`, and `platform` are - plain strings validated against a real parser (`spdx`, `semver`, - `target-lexicon`) at import time rather than carried as a parsed type, - since nothing downstream needs more than the canonical string back. - `Store::store_tree`/`ref_tree` write and read the document's root tree - directly, since a `src` (or `Bin::Embedded` `bin`) directory underneath - must be written into the object database before the document that - references it can be assembled. - -Authored collections:: - A collection whose documents treat the commit as the record - (`comments/<genesis-hash>`) writes through `Store::store_item_authored`, - which stamps the acting human on the ref's commit, and reads authorship - back through `Store::provenance`/`item_provenance`: the genesis commit's - author created the document, the tip commit's author last updated it. - 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 (a username, a - check name, a colon-form fingerprint, a content hash) 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` rather than silently injecting an extra path - component or colliding with a sibling entry. - -The map-document helper:: - `Store::load_map`/`store_map` is the shared conversion every "named entries - on one ref" collection needs — a scalar-keyed map document whose flattened - public item type carries the key once, not the hand-written wrapper - document (a `Checks { checks: BTreeMap<...> }`-shaped struct) each of - `checks`, `revocations`, and run outcomes previously declared for itself. - -Domain validation:: - A type that carries an invariant the type system cannot express (a - member's validity window must not be inverted) exposes its own `validate` - and calls it from its own `store`, returning `git_store::Error::Invalid` — - distinct from `InvalidKey`, which is about the collection key rather than - the document's content. This runs for every caller of that `store` - function, not just the CLI command that happens to build the value today. - -Closed sets are enums, not strings:: - `Issue.state`, and a check run's status, are closed sets the specification - enumerates (<<issues.ref>>, <<checks.outcomes>>); they are modeled as - `facet`-derived enums (`issues::State`, `checks::Status`) rather than - `String`, so an invalid value cannot be constructed instead of merely being - discouraged by a comment. - -Format stability:: - Unchanged from <<storage.meta-ref>>: a document's `Facet` shape is its - on-disk format, and every document type carries a load test against a - hand-built fixture in the exact on-disk layout. Introducing `load_map` and - the closed-set enums above changed several documents' on-disk shape - (`checks/<name>`, `revoked/<fingerprint>`, `runs/<commit>` outcomes, and - `issues/<id>` state each moved a scalar to a subtree); each carries an - updated fixture rather than a version negotiation, acceptable pre-1.0.
docs/spec/web-auth.adoc @@ -1,69 +1,0 @@ -== Web Authentication - -[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 the -nonce 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 pasted 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 for -a browser without the CLI installed. --- - -[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, so a cross-site request (which cannot read the cookie) cannot -act as the signed-in user. -Sessions MUST be held only in server memory; no session state is persisted to -disk. -A session MUST store only the member's public key and display label — no -private key is ever held or transmitted. -Signing out MUST drop the session from server memory and clear the session -cookie; the sign-out POST MUST itself carry the CSRF token, so a cross-site -request cannot force a sign-out. --- - -[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 and pushed onto `refs/meta/config` -via the signed push; the staging ref MUST be deleted whether or not the push -succeeds. -If the server is not configured with a 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 by `members::allowed_signers` (a `WebAuthn` member never -produces a push line) and by the web write path (`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. Tracked so this section states an -intended feature rather than current behavior. - -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. ---