feat: wire up tracey requirement tracking against docs/spec
commit
524e423feat: wire up tracey requirement tracking against docs/spec
Registers a tracey config so the specification’s coverage against the Rust implementation can be tracked going forward. The single-file spec is split into one file per topic so tracey’s uncovered/status views group requirements by section instead of dumping a flat list.
feat: add .config/tracey/config.styx tracking docs/spec//*.adoc against crates//*.rs docs: split docs/specification.adoc into per-topic files under docs/spec/ Assisted-by: Claude:claude-sonnet-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
.config/tracey/config.styx
@@ -1,0 +1,12 @@
+specs (
+ {
+ name git-ents
+ include (docs/spec/**/*.adoc)
+ impls (
+ {
+ name rust
+ include (crates/**/*.rs)
+ }
+ )
+ }
+)
docs/spec/account.adoc
@@ -1,0 +1,23 @@
+== 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,0 +1,50 @@
+== 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/checks.adoc
@@ -1,0 +1,100 @@
+== Checks
+
+[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, and an optional list of dependencies (names of sibling
+checks that must pass first).
+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, and any dependency cycle 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.
+--
+
+[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, 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.
+--
+
+[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.
+
+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"]
+.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.
+--
+
+[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.
+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.
+--
+
+[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 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.
+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.
+--
docs/spec/cli.adoc
@@ -1,0 +1,106 @@
+== Client CLI
+
+[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 read-only command (`members list`, `members check`, `checks 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.
+This is 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; adding a member MUST
+ create 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 (see <<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.
+--
+
+[role="requirement", id="cli.account-checks"]
+.Account and Check 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 checks` MUST provide `list`, `add`, and `remove` over the
+`refs/meta/checks` set (<<checks.definition>>).
+--
+
+[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: 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.
+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/comments.adoc
@@ -1,0 +1,52 @@
+== 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: 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.
+--
+
+[role="requirement", id="comments.anchor"]
+.Anchors
+--
+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
+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.
+--
+
+[role="requirement", id="comments.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).
+Projection MUST follow renames and MUST work between any two commits —
+forwards, backwards, or across unrelated history.
+An outdated or deleted projection MUST NOT lose the comment: the original
+anchor remains displayable.
+--
docs/spec/compat.adoc
@@ -1,0 +1,65 @@
+== 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,0 +1,11 @@
+== 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/conformance.adoc
@@ -1,0 +1,79 @@
+= 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`
+|`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-ents/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref`
+|`members.trust` |`git-ents/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}`
+|`members.provenance` |`git-ents/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-ents/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member`
+|`members.window` |`git-ents/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window`
+|`members.allowed-signers` |`git-ents/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-ents/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations`
+|`revocations.ca` |`git-ents/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched`
+|`auth.bootstrap` |`git-ents-server/src/verify.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case)
+|`auth.signed-push` |`git-ents-server/src/verify.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)_
+|`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/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent`
+|`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `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_check`
+|`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_
+|`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::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-ents`)_
+|`checks.sandbox` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_
+|`checks.outcomes` |`git-ents/src/checks.rs` (`Status`, `record`/`update_run`); `git-ents-server/src/checks.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`checks::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_log_url`
+|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`checks debug`) | _(requires a live Sprite; manually verified)_
+|`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`) |`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.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-ents-server/src/checks.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); `checks.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-ents-server/src/verify.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*`
+|`compat.openssh-signed-push` |`git-ents-server/src/verify.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key`
+|`compat.sprite` |`git-ents-server/src/checks.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/deployment.adoc
@@ -1,0 +1,19 @@
+== 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,0 +1,27 @@
+== 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,0 +1,79 @@
+== 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/namespace.adoc
@@ -1,0 +1,31 @@
+== 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,0 +1,59 @@
+== 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/overview.adoc
@@ -1,0 +1,13 @@
+= Specification
+Joey Carpinelli <joseph.carpinelli@icloud.com>
+
+[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.
+
+[CAUTION]
+This specification is not yet stable and will grow as the project develops.
docs/spec/protocol.adoc
@@ -1,0 +1,34 @@
+== 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,0 +1,19 @@
+== 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/storage.adoc
@@ -1,0 +1,115 @@
+== 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.
+
+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,0 +1,69 @@
+== 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.
+--
docs/spec/web.adoc
@@ -1,0 +1,74 @@
+== Web UI
+
+[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.
+--
+
+[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 the following tabs:
+
+Files::
+ The repository's file tree, browsable to arbitrary depth, with syntax-
+ highlighted blob views and rendered AsciiDoc and Markdown files.
+
+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 files 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.
+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.
+--
+
+[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>>).
+--
docs/specification.adoc
@@ -1,1046 +1,0 @@
-= Specification
-Joey Carpinelli <joseph.carpinelli@icloud.com>
-
-[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.
-
-[CAUTION]
-This specification is not yet stable and will grow as the project develops.
-
-== Requirements
-
-=== 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.
-
-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.
-
-=== 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.
---
-
-=== 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.
---
-
-=== 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"`.
---
-
-=== 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.
---
-
-=== 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`.
---
-
-=== Client CLI
-
-[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 read-only command (`members list`, `members check`, `checks 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.
-This is 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; adding a member MUST
- create 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 (see <<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.
---
-
-[role="requirement", id="cli.account-checks"]
-.Account and Check 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 checks` MUST provide `list`, `add`, and `remove` over the
-`refs/meta/checks` set (<<checks.definition>>).
---
-
-[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: 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.
-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.
---
-
-=== 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.
---
-
-=== 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).
---
-
-=== Checks
-
-[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, and an optional list of dependencies (names of sibling
-checks that must pass first).
-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, and any dependency cycle 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.
---
-
-[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, 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.
---
-
-[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.
-
-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"]
-.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.
---
-
-[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.
-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.
---
-
-[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 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.
-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.
---
-
-=== 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.
---
-
-=== 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: 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.
---
-
-[role="requirement", id="comments.anchor"]
-.Anchors
---
-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
-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.
---
-
-[role="requirement", id="comments.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).
-Projection MUST follow renames and MUST work between any two commits —
-forwards, backwards, or across unrelated history.
-An outdated or deleted projection MUST NOT lose the comment: the original
-anchor remains displayable.
---
-
-=== Web UI
-
-[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.
---
-
-[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 the following tabs:
-
-Files::
- The repository's file tree, browsable to arbitrary depth, with syntax-
- highlighted blob views and rendered AsciiDoc and Markdown files.
-
-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 files 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.
-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.
---
-
-[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>>).
---
-
-=== 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.
---
-
-=== 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.
---
-
-=== 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`).
---
-
-=== 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.
---
-
-[appendix]
-== 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`
-|`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-ents/src/members.rs` (`MEMBER_NS`, `load_all`/`store`) |`members::load_all_unions_every_member_ref`
-|`members.trust` |`git-ents/src/members.rs` (`Trust`) |`members::store_then_load_round_trips_a_{member,ca_member,webauthn_member}`
-|`members.provenance` |`git-ents/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-ents/src/members.rs` (`Member.account`) |`members::store_then_load_round_trips_a_member`
-|`members.window` |`git-ents/src/members.rs` (`Member::validate`, `valid_timestamp`) |`members::validate_rejects_*`, `store_rejects_a_member_with_an_inverted_window`
-|`members.allowed-signers` |`git-ents/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-ents/src/revocations.rs` |`revocations::store_then_load_round_trips_the_revocations`
-|`revocations.ca` |`git-ents/src/members.rs` (`without_revoked`, CA branch untouched) |`members::without_revoked_leaves_ca_members_untouched`
-|`auth.bootstrap` |`git-ents-server/src/verify.rs` (`pre_receive`, empty-members early return) |`pre_receive` integration tests (bootstrap case)
-|`auth.signed-push` |`git-ents-server/src/verify.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)_
-|`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/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent`
-|`checks.definition` |`git-ents/src/checks.rs` (`load`/`store`, `CHECKS_REF`, `order`) |`checks::store_then_load_round_trips_the_check_set`, `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_check`
-|`checks.post-receive` |`git-ents-server/src/checks.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_
-|`checks.worker` |`git-ents-server/src/checks.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`checks::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-ents`)_
-|`checks.sandbox` |`git-ents-server/src/checks.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_
-|`checks.outcomes` |`git-ents/src/checks.rs` (`Status`, `record`/`update_run`); `git-ents-server/src/checks.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`checks::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_log_url`
-|`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`checks debug`) | _(requires a live Sprite; manually verified)_
-|`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`) |`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.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-ents-server/src/checks.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); `checks.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-ents-server/src/verify.rs` (`verify_certificate`), `web/write.rs` (`verify_login_signature`) |`pre_receive::*`, `web_edit::*`
-|`compat.openssh-signed-push` |`git-ents-server/src/verify.rs` (`GIT_PUSH_CERT`, `GIT_PUSH_CERT_NONCE_STATUS`) |`pre_receive::rejects_a_push_signed_by_an_expired_key`
-|`compat.sprite` |`git-ents-server/src/checks.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)_
-|===