docs: restructure the spec around the load-bearing abstractions
commit
4243594docs: restructure the spec around the load-bearing abstractions
One file per abstraction — meta-ref (with members, revocations, account, config, issues, comments as children), anchor, signed-push, checks, server (namespace, compat, deployment, nonfunctional), web, cli — prose tightened, all 68 requirement ids preserved and tracey-validated.
docs: add server.embeddable and web.render-registry requirements Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
docs/spec/checks.adoc
@@ -1,112 +1,115 @@
-== Checks
+== The Check
+
+A check is data (`refs/meta/checks`), not configuration living outside the
+repo; the toolchain that feeds it is a git tree
+(`refs/meta/toolchains/<name>`).
+The repository carries its own CI definition and environment, and nothing
+ever blocks a push.
[role="requirement", id="checks.definition"]
.Check Set Definition
--
-The configured checks for a repository MUST be stored at `refs/meta/checks` as
-a map from check name to a check body holding an optional shell command, an
-optional sandbox image, an optional list of dependencies (names of sibling
-checks that must pass first), and an optional list of toolchain names
-(<<checks.toolchains>>).
-A check with no command is a composite: it runs nothing itself and derives its
-outcome from its dependencies alone.
+The configured checks for a repository MUST be stored at `refs/meta/checks`
+as a map from check name to a check body holding an optional shell command,
+an optional sandbox image, an optional list of dependencies (names of
+sibling checks that must pass first), and an optional list of toolchain
+names (<<checks.toolchains>>).
+A check with no command is a composite: it runs nothing itself and derives
+its outcome from its dependencies alone.
A check's definition living on a meta ref means a branch under check cannot
rewrite the check set that gates it.
The dependency graph is fully static — no conditional edges, no runtime
-expansion — and MUST be validated when the set is written: a dependency naming
-no configured check, a duplicate or self edge, a check with neither a command
-nor dependencies, any dependency cycle, and a toolchain name that is not a
-valid ref-path segment MUST each be rejected before the set is stored. A check
-that sets an image MUST be rejected until the sandbox can honor one, rather
-than the image being silently ignored; the field is reserved in the format so
-honoring it later is not a data migration.
+expansion — and MUST be validated when the set is written: a dependency
+naming no configured check, a duplicate or self edge, a check with neither
+a command nor dependencies, any dependency cycle, and a toolchain name that
+is not a valid ref-path segment MUST each be rejected before the set is
+stored.
+A check that sets an image MUST be rejected until the sandbox can honor
+one; the field is reserved in the format so honoring it later is not a data
+migration.
--
[role="requirement", id="checks.toolchains"]
.Check Toolchains
--
A check's optional list of toolchain names MUST each be a valid ref-path
-segment (<<storage.meta-ref>>'s collection-key rule), validated when the set
-is written; whether a named toolchain actually exists MUST be checked
-server-side at job time instead, since it names an entry in a different ref
-namespace (`refs/meta/toolchains/<name>`, `git-toolchain`) the check set
-itself does not enumerate.
+segment, validated when the set is written; whether a named toolchain
+actually exists MUST be checked server-side at job time instead, since it
+names an entry in a different ref namespace (`refs/meta/toolchains/<name>`,
+`git-toolchain`) the check set itself does not enumerate.
--
[role="requirement", id="checks.post-receive"]
.Asynchronous Queuing
--
After a successful push is accepted by `pre-receive`, a `post-receive` hook
-MUST enqueue a check job for each updated branch whose new tip is non-zero and
-is not a `refs/meta/*` ref.
+MUST enqueue a check job for each updated branch whose new tip is non-zero
+and is not a `refs/meta/*` ref.
The hook MUST record an initial run status of `queued` for each check
immediately, so the UI reflects the job before the worker picks it up.
The hook MUST NOT run checks itself; it MUST return as soon as jobs are
-enqueued, so the push is never blocked on check execution.
-Job files MUST be written to a tmp path and renamed into place so the worker
-never observes a partial file.
+enqueued.
+Job files MUST be written to a tmp path and renamed into place so the
+worker never observes a partial file.
--
[role="requirement", id="checks.worker"]
.Check Worker
--
A persistent server-side worker MUST drain the job queue.
-Jobs MUST be grouped by repository; each repository's jobs MUST be processed
-serially to prevent concurrent runs from colliding in its sandbox.
-Separate repositories MUST be processed concurrently, so a slow repository's
-backlog does not block others.
+Jobs MUST be grouped by repository; each repository's jobs MUST be
+processed serially to prevent concurrent runs from colliding in its
+sandbox, while separate repositories MUST be processed concurrently.
-Within a job the checks MUST settle in topological dependency order, so every
-dependency's outcome is terminal before its dependents are reached.
+Within a job the checks MUST settle in topological dependency order, so
+every dependency's outcome is terminal before its dependents are reached.
A check whose dependency did not pass MUST be recorded `skipped` without
-running; a composite's outcome is derived from its dependencies — `pass` when
-all passed, `fail` when any failed or errored, `skipped` otherwise.
-The worker MUST re-validate the dependency graph before running (a hand-crafted
-push can land a set the CLI would have rejected) and finalize the run as
-`error` when it is invalid.
+running; a composite's outcome is derived from its dependencies — `pass`
+when all passed, `fail` when any failed or errored, `skipped` otherwise.
+The worker MUST re-validate the dependency graph before running (a
+hand-crafted push can land a set the CLI would have rejected) and finalize
+the run as `error` when it is invalid.
--
[role="requirement", id="checks.sandbox"]
.Sprite Sandbox
--
-Each check MUST run inside a Fly.io Sprite: a persistent, hardware-isolated
-sandbox, one per repository, so build caches survive between pushes.
-Before running checks the worker MUST sync the pushed tree into the Sprite's
-work directory via `git archive` piped to `tar -x`.
-The worker MUST configure the `sprite` CLI from `SPRITES_TOKEN` before each
-run via `sprite auth setup`, so the credential stays current without restart.
+Each check MUST run inside a Fly.io Sprite: a persistent,
+hardware-isolated sandbox, one per repository, so build caches survive
+between pushes.
+Before running checks the worker MUST sync the pushed tree into the
+Sprite's work directory via `git archive` piped to `tar -x`, and MUST
+configure the `sprite` CLI from `SPRITES_TOKEN` via `sprite auth setup`.
Before running a check that names a toolchain, the worker MUST resolve each
distinct toolchain named across the job's checks (<<checks.toolchains>>) to
-its typed `bin` tree (`git-toolchain`'s `Toolchain::bin`, not the whole
-document) and extract that tree into the Sprite at a hash-keyed directory,
-skipping the extraction when that directory already exists — the Sprite's
-persistent filesystem is the cache, so the same toolchain is never
-re-extracted across pushes. A check's command MUST then run with each of its
-named toolchains' extracted `bin` directory prefixed onto `PATH`, in the
-order they are declared, so an earlier toolchain takes precedence over a
-later one on a name collision.
+its typed `bin` tree (`Toolchain::bin`, not the whole document) and extract
+it into the Sprite at a hash-keyed directory, skipping the extraction when
+that directory already exists — the Sprite's persistent filesystem is the
+cache.
+A check's command MUST then run with each of its named toolchains'
+extracted `bin` directory prefixed onto `PATH`, in declaration order, so an
+earlier toolchain wins a name collision.
--
[role="requirement", id="checks.outcomes"]
.Run Recording
--
-Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per checked
-commit.
-Each ref's commit chain is the run history; each commit's date is the run time,
-so no timestamp is duplicated in the document tree.
+Run outcomes MUST be stored at `refs/meta/runs/<commit>`, one ref per
+checked commit.
+Each ref's commit chain is the run history; each commit's date is the run
+time, so no timestamp is duplicated in the document tree.
Outcome values progress: `queued` → `running` → `pass` / `fail` / `error` /
-`skipped` (a check whose dependency did not pass, per <<checks.worker>>).
-A run that cannot complete due to an infrastructure error (unreachable Sprite,
-failed sync) MUST be finalized as `error` rather than left stuck at `running`.
-A single check that exceeds a 30-minute timeout MUST be recorded `error` rather
-than blocking the worker thread indefinitely.
-A check definition and a run outcome MAY each carry additional metadata beyond
-a single command/outcome string (for example a run's duration and log URL).
-Optional fields absent from an older record MUST load as unset.
-A check/outcome name is the map key and MUST NOT be stored redundantly inside
-the record.
+`skipped` (per <<checks.worker>>).
+A run that cannot complete due to an infrastructure error MUST be finalized
+as `error` rather than left stuck at `running`; a single check exceeding a
+30-minute timeout MUST be recorded `error`.
+A check definition and a run outcome MAY each carry additional metadata (a
+run's duration and log URL); optional fields absent from an older record
+MUST load as unset.
+A check/outcome name is the map key and MUST NOT be stored redundantly
+inside the record.
--
[role="requirement", id="checks.debug"]
@@ -116,9 +119,8 @@
a repository's checks Sprite (`git ents checks debug`), brokered by the
server over a WebSocket at the reserved path `/_debug/<repo>`.
The server holds the only Fly credential (`SPRITES_TOKEN`); a member MUST
-NOT need one of their own.
-The broker MUST refuse the connection without a signed-in session.
+NOT need one of their own, and the broker MUST refuse the connection
+without a signed-in session.
Local terminal resizes MUST be forwarded to the remote pseudo-terminal as
-control frames distinct from the byte stream, so the remote shell tracks the
-member's window size.
+control frames distinct from the byte stream.
--
docs/spec/cli.adoc
@@ -1,15 +1,18 @@
== Client CLI
+The porcelain.
+Its added value over the underlying primitives is remotes: every command is
+a fetch, a typed edit, and a signed push — never a private API.
+
[role="requirement", id="cli.remote-admin"]
.Remote Administration Over Git
--
The `git-ents` CLI MUST administer a remote's trust and metadata refs as
ordinary signed git pushes, with no separate admin API or server endpoint.
-A mutating command MUST fetch the relevant `refs/meta/*` ref(s) from the named
-remote (defaulting to `origin`) into the local clone, load the typed document,
-apply the change, store it, and push the updated ref back — signed per the
-client's git config, through the same `pre-receive` gate a content push
-traverses.
+A mutating command MUST fetch the relevant `refs/meta/*` ref(s) from the
+named remote (defaulting to `origin`), load the typed document, apply the
+change, store it, and push the updated ref back — signed per the client's
+git config, through the same `pre-receive` gate a content push traverses.
A read-only command (`members list`, `members check`, `checks list`,
`comment list`/`show`) MUST only fetch, never push.
--
@@ -19,18 +22,19 @@
--
A mutating command run at an interactive terminal MUST prompt for any
required field left unset rather than fail.
-The same invocation without a TTY MUST fail with an error naming the missing
-field; it MUST NOT hang waiting for input.
+The same invocation without a TTY MUST fail with an error naming the
+missing field; it MUST NOT hang waiting for input.
--
[role="requirement", id="cli.compare-and-swap"]
.Optimistic Concurrency
--
Every CLI push MUST be a compare-and-swap pinned to the ref tip observed at
-fetch time, expressed as `--force-with-lease=<ref>:<expected>` together with
-`--force-if-includes`, so a change made on the remote since the fetch is
-rejected rather than clobbered; a create pins the lease to the zero object id.
-This is the client-side counterpart to <<storage.concurrency>>.
+fetch time, expressed as `--force-with-lease=<ref>:<expected>` together
+with `--force-if-includes`, so a change made on the remote since the fetch
+is rejected rather than clobbered; a create pins the lease to the zero
+object id.
+The client-side counterpart to <<storage.concurrency>>.
--
[role="requirement", id="cli.members"]
@@ -38,19 +42,21 @@
--
The CLI MUST provide, under `git ents members`:
-* `list` — render the live member set, each key with its fingerprint, validity
- window, and a flag when it appears on the revocation list.
-* `add` — authorize a leaf key or pin a certificate authority for a username,
- with optional `valid-after` / `valid-before` window; adding a member MUST
- create the ref when absent.
+* `list` — render the live member set, each key with its fingerprint,
+ validity window, and a flag when it appears on the revocation list.
+* `add` — authorize a leaf key or pin a certificate authority for a
+ username, with optional `valid-after` / `valid-before` window, creating
+ the ref when absent.
* `remove` — delete the member's ref.
-* `revoke` / `unrevoke` — add or remove a fingerprint on `refs/meta/revoked`.
+* `revoke` / `unrevoke` — add or remove a fingerprint on
+ `refs/meta/revoked`.
* `check` — report whether a key is a member of the remote and echo the
- client's signing config (`gpg.format`, `user.signingkey`, `push.gpgSign`).
-* `setup` — configure signed pushes (see <<auth.client-setup>>).
+ client's signing config (`gpg.format`, `user.signingkey`,
+ `push.gpgSign`).
+* `setup` — configure signed pushes (<<auth.client-setup>>).
-Revoking the operator's own last authorizing key MUST prompt for confirmation
-before proceeding, since it can lock the operator out of the remote.
+Revoking the operator's own last authorizing key MUST prompt for
+confirmation, since it can lock the operator out of the remote.
--
[role="requirement", id="cli.account-checks"]
@@ -70,28 +76,26 @@
* `import` — write a local `bin` directory (and, optionally, a `src`
directory) plus a license, version, and platform to
- `refs/meta/toolchains/<name>` on a remote and push it, creating the ref
- when absent. `bin` MUST be non-empty; `license` MUST be a valid SPDX
- license expression; `version` MUST be a valid semver version; `platform`
- MUST be a valid target triple.
-* `import --from <recipe>` — derive `bin`/`src`/`license`/`version`/
- `platform` from a local toolchain install instead of supplying them by
- hand, via a named recipe (currently only `rustup`, selected further by
- `--spec <name>`, e.g. `stable`); any of the fields also passed explicitly
- override the recipe's value. By default a recipe capable of pointing at a
- distributor's own hosted, hash-pinned archives (rust-lang's dist tarballs,
- for `rustup`) records those as the toolchain's `bin` instead of importing
- local bytes, sparing the repository the toolchain's own bytes; `--embed`
- forces the recipe to import the local install's actual `bin` bytes
- instead, as `import` without `--from` always does.
-* `list` — render every toolchain configured on a remote with its `bin`
- (a tree id, or a component count when hosted externally), version,
- platform, and license.
-* `export` — recreate a remote's named toolchain's `bin` (and `src`, if
- present) under a local destination directory, restoring the executable bit
- and symlinks — fetching and sha256-verifying `bin`'s components first when
- hosted externally rather than embedded — and print the version, platform,
- and license; read-only, per <<cli.remote-admin>>.
+ `refs/meta/toolchains/<name>` on a remote, creating the ref when absent.
+ `bin` MUST be non-empty; `license` MUST be a valid SPDX expression;
+ `version` MUST be valid semver; `platform` MUST be a valid target triple.
+* `import --from <recipe>` — derive the fields from a local toolchain
+ install via a named recipe (currently only `rustup`, selected further by
+ `--spec <name>`, e.g. `stable`); explicitly passed fields override the
+ recipe.
+ A recipe capable of pointing at a distributor's own hosted, hash-pinned
+ archives (rust-lang's dist tarballs, for `rustup`) records those as the
+ toolchain's `bin` by default, sparing the repository the bytes; `--embed`
+ forces importing the local install's actual `bin` bytes, as `import`
+ without `--from` always does.
+* `list` — render every toolchain on a remote with its `bin` (a tree id,
+ or a component count when hosted externally), version, platform, and
+ license.
+* `export` — recreate a remote toolchain's `bin` (and `src`, if present)
+ under a local destination, restoring the executable bit and symlinks —
+ fetching and sha256-verifying externally hosted components first — and
+ print the version, platform, and license; read-only, per
+ <<cli.remote-admin>>.
* `remove` — delete the toolchain's ref on a remote.
--
@@ -103,10 +107,10 @@
* `add` — anchor a comment to a path, an optional 1-based inclusive line
range, and a revision (defaulting to `HEAD`), optionally attached to an
issue by its genesis id, and push it.
-* `list` / `show` — project each comment's anchor onto a chosen revision and
- report its projection state (<<comments.projection>>); `show` MUST print
- the anchored text derived from the blob, and both MUST report the author
- recovered from the ref's commits (<<comments.authorship>>).
+* `list` / `show` — project each comment's anchor onto a chosen revision
+ and report its projection state (<<comments.projection>>); `show` MUST
+ print the anchored text derived from the blob, and both MUST report the
+ author recovered from the ref's commits (<<comments.authorship>>).
* `remove` — delete the comment's ref.
Comment commands MUST follow the same remote-administration flow as member
@@ -117,10 +121,9 @@
.CLI Sign-In
--
`git ents login` MUST complete the same challenge–response sign-in the
-browser uses (<<web.auth.challenge>>) with no manual copy-and-paste: the CLI
-fetches a challenge from the remote, signs it with the client's configured
-signing key, submits the proof, and receives the same session a browser
-sign-in yields.
+browser uses (<<web.auth.challenge>>) with no manual copy-and-paste: fetch
+a challenge from the remote, sign it with the configured signing key,
+submit the proof, and receive the same session a browser sign-in yields.
The server MUST expose the challenge and verify steps as plain-text
endpoints suitable for non-browser clients.
--
@@ -128,11 +131,11 @@
[role="requirement", id="cli.key-resolution"]
.Key Resolution and Fingerprints
--
-The key a command operates on MUST default to `user.signingkey`, accepting an
-inline `key::` value, a `.pub` file, or a private key whose public half is
-derived with `ssh-keygen -y`; `members setup` MAY generate a new
+The key a command operates on MUST default to `user.signingkey`, accepting
+an inline `key::` value, a `.pub` file, or a private key whose public half
+is derived with `ssh-keygen -y`; `members setup` MAY generate a new
`~/.ssh/id_ed25519` when none exists.
-A key's fingerprint MUST be the MD5 colon form, whose separators are safe as a
-ref-path/tree-entry segment, unlike the slashes a base64 SHA256 fingerprint
-would introduce.
+A key's fingerprint MUST be the MD5 colon form, whose separators are safe
+as a ref-path/tree-entry segment, unlike the slashes a base64 SHA256
+fingerprint would introduce.
--
docs/spec/conformance.adoc
@@ -13,6 +13,7 @@
|`storage.bare` |`git-ents-server/src/http.rs` (`backend`, repo auto-init) |`server::push_then_clone_round_trip`
|`storage.meta-ref` |`git-store/src/lib.rs` (`Store::load`/`store`) |per-document `loads_the_on_disk_*_format` fixture tests
|`storage.concurrency` |`git-store/src/merge.rs` (`three_way_merge`), `Store::store_impl`/`amend` |`git-store::tests::merge_*`, `amend_fails_closed_on_a_race_instead_of_merging`
+|`server.embeddable` |`git-ents-server/src/lib.rs` (`pub` `Args`/`run`, hook subcommands); `git-ents/src/main.rs` (`Top::Server`) | _(structural)_
|`protocol.git` |`git-ents-server/src/http.rs` (`backend`) |`server::push_then_clone_round_trip`
|`protocol.routing` |`git-ents-server/src/http.rs` (`get_request`/`post_request`, `detects_pushes`) |`http::tests::detects_pushes`, `routes_browser_gets`
|`namespace.url` |`README.adoc`/deployment config | _(documentation only)_
@@ -53,6 +54,7 @@
|`comments.authorship` |`git-store/src/lib.rs` (`store_item_authored`, `provenance`); `git-comment/src/lib.rs` (`provenance`) |`git-store::provenance_recovers_the_creating_and_updating_authors`, `git-comment::provenance_comes_from_the_commits_not_the_document`
|`comments.anchor` |`git-anchor/src/lib.rs` (`Anchor`, `capture`, `snippet`) |`git-anchor::capture_records_the_commit_and_blob_and_snippet_derives_the_text`, `capture_rejects_a_missing_path_and_an_oversized_range`
|`comments.projection` |`git-anchor/src/lib.rs` (`project`, `Projection`) |`git-anchor::unchanged_file_projects_as_current`, `a_pure_rename_relocates_with_the_same_lines`, `an_edit_inside_the_range_is_outdated`, `a_deleted_file_projects_as_deleted`, `projection_works_backwards_onto_an_ancestor`
+|`web.render-registry` |`git-ents-server/src/render.rs` (`to_html`/`to_text`, `mime_for_name`) | _(passthrough fallback exercised by web/CLI render paths)_
|`web.server-rendered` |`git-ents-server/src/web/*.rs` (Askama/maud templates, no client JS required) | _(manual UI verification)_
|`web.index` |`git-ents-server/src/web/mod.rs` (`index`) | _(manual UI verification)_
|`web.tabs` |`git-ents-server/src/web/pages.rs`, `templates/issues.html` | _(manual UI verification)_
docs/spec/overview.adoc
@@ -4,10 +4,25 @@
[abstract]
`git-ents` is a Git forge: a self-hosted, membership-gated Git server with a
browser UI, a CLI, asynchronous CI checks, an issue tracker, and anchored
-code comments, all stored as typed documents on git meta-refs.
-Every piece of state — members, configuration, checks, runs, issues, comments
-— lives in the repository itself, versioned and auditable, with no external
-database.
+code comments.
+Every piece of state — members, configuration, checks, runs, issues,
+comments — lives in the repository itself as typed documents on git
+meta-refs, versioned and auditable, with no external database.
+
+The spec is organized around the load-bearing abstractions (rationale in
+`docs/abstractions.adoc`), each a parent section with its dependent entities
+as children:
+
+* `meta-ref.adoc` — the meta-ref and the typed tree; the entities stored on
+ them (members, revocations, account, config, issues, comments).
+* `anchor.adoc` — durable pointers into content, and their projection.
+* `signed-push.adoc` — the only write path, for git and browser alike.
+* `checks.adoc` — checks and the toolchains that feed them.
+* `server.adoc` — the embeddable server: protocol, namespace, compatibility,
+ deployment, nonfunctional bounds.
+* `web.adoc` — the web UI and the rendering registry.
+* `cli.adoc` — the porcelain.
+* `conformance.adoc` — requirement → implementation → test map.
[CAUTION]
This specification is not yet stable and will grow as the project develops.
docs/spec/web.adoc
@@ -1,19 +1,32 @@
== Web UI
+The browser UI is server-rendered HTML over the same state the CLI reads;
+documents render through one MIME-keyed registry shared by both.
+
+[role="requirement", id="web.render-registry"]
+.Rendering Registry
+--
+Documents MUST be rendered by MIME type through a single registry shared by
+the web UI and the CLI, producing HTML for the browser and plain text for
+the terminal from the same source.
+An unregistered MIME type MUST fall through to passthrough rather than an
+error, since MIME is an open namespace.
+--
+
[role="requirement", id="web.server-rendered"]
.Server-Rendered HTML
--
All browser-facing pages MUST be rendered server-side with no required
JavaScript on the client.
-Page navigation MUST be ordinary links; folder expansion and file viewing MUST
-be plain GET requests to the server.
+Page navigation MUST be ordinary links; folder expansion and file viewing
+MUST be plain GET requests.
--
[role="requirement", id="web.index"]
.Repository Index
--
-The web root (`GET /`) MUST render an index of the repositories discoverable
-under the data directory, each linking to its overview page.
+The web root (`GET /`) MUST render an index of the repositories
+discoverable under the data directory, each linking to its overview page.
When no repository exists yet, the index MUST show a blank-slate prompt
explaining that a push creates one, rather than an error.
--
@@ -21,11 +34,11 @@
[role="requirement", id="web.tabs"]
.Repository Tabs
--
-Each repository's web UI MUST provide at minimum the following tabs:
+Each repository's web UI MUST provide at minimum:
Files::
- The repository's file tree, browsable to arbitrary depth, with syntax-
- highlighted blob views and rendered AsciiDoc and Markdown files.
+ The file tree, browsable to arbitrary depth, with syntax-highlighted blob
+ views and rendered AsciiDoc and Markdown.
Commits::
The commit history with diff views.
@@ -48,27 +61,25 @@
.Syntax Highlighting
--
Blob views MUST syntax-highlight source files using a compile-time language
-registry.
-AsciiDoc and Markdown files MUST be rendered to HTML.
+registry; AsciiDoc and Markdown MUST be rendered to HTML.
A blob rendered as a formatted document MUST offer a toggle between the
-rendered document and its syntax-highlighted source.
+rendered document and its highlighted source.
Blob gutter line numbers MUST be self-linking anchors (`#L<n>`).
-Files larger than 2 MiB MUST be truncated rather than loaded in full, to bound
-memory cost per request.
+Files larger than 2 MiB MUST be truncated (<<nonfunctional.memory-cap>>).
--
[role="requirement", id="web.comments"]
.Comments in the Browser
--
A file's anchored comments MUST be listed under its blob view, projected
-onto the revision being viewed (<<comments.projection>>), linking their line
-range into the blob's gutter and flagged when the projection reports them
-outdated.
+onto the revision being viewed (<<comments.projection>>), linking their
+line range into the blob's gutter and flagged when the projection reports
+them outdated.
A signed-in member MUST be able to add a comment from the file view; the
-comment anchors to the viewed tip and MUST land as a signed push through the
-same `pre-receive` gate a settings edit traverses (<<web.auth.edit>>), with
-the signed-in human as author.
-Commenting MUST NOT require admin-registered provenance: issues and comments
-are exactly the writable surface allowed to a self-attested member
-(<<members.provenance>>).
+comment anchors to the viewed tip and MUST land as a signed push through
+the same `pre-receive` gate a settings edit traverses (<<web.auth.edit>>),
+with the signed-in human as author.
+Commenting MUST NOT require admin-registered provenance: issues and
+comments are exactly the writable surface allowed to a self-attested
+member (<<members.provenance>>).
--
docs/spec/comments.adoc → docs/spec/anchor.adoc
@@ -1,27 +1,9 @@
-== Comments
+== The Anchor
-[role="requirement", id="comments.ref"]
-.Comment Documents
---
-Each code comment MUST be stored at `refs/meta/comments/<id>` as a `Comment`
-document with fields: `body`, `anchor` (<<comments.anchor>>), and an
-optional `issue` cross-referencing an issue by its genesis id
-(<<issues.id>>); an absent `issue` means a free-standing comment.
-The identifier MUST follow the same genesis-key rule as an issue: the object
-id of the originating object, or of the comment's own initial content, and
-it is never renamed.
-One ref per comment keeps comments independently loadable and separately
-historied; the ref's commit chain is the comment's edit history.
---
-
-[role="requirement", id="comments.authorship"]
-.Authorship From the Commit Chain
---
-A comment's author and timestamps MUST NOT be stored in the document tree.
-The creator is the author of the ref's first commit and the last editor is
-the author of its tip commit; both MUST be recovered from the commit chain
-at read time.
---
+A durable pointer into content: a blob (and optionally a line range) at a
+commit.
+Anchors resolve and project independently of comments, so other tools
+(reviews, TODO trackers, blame overlays) can reuse them.
[role="requirement", id="comments.anchor"]
.Anchors
docs/spec/account.adoc
@@ -1,23 +1,0 @@
-== Account
-
-[role="requirement", id="account.ref"]
-.Account Profile
---
-A repository becomes an account repository by carrying a `refs/meta/account`
-ref.
-The `Account` document at that ref MUST hold: `username`, `display_name`,
-`bio`, and `created_at` (seconds since the Unix epoch).
-The username is authoritative in the document; the repository path is
-convention, not trust.
---
-
-[role="requirement", id="account.genesis"]
-.Account Identity
---
-An account's stable, path-independent identity MUST be the content hash of
-the first `Account` document ever recorded on its ref — fixed at creation,
-so later profile edits never change it and a reference to the account
-(<<members.account>>) survives the account repository moving.
-The identity MUST be derived from the ref's history, never stored as a
-field.
---
docs/spec/auth.adoc
@@ -1,50 +1,0 @@
-== Push Authentication
-
-[role="requirement", id="auth.bootstrap"]
-.Bootstrap Window
---
-When no `refs/meta/member/*` refs exist the repository MUST accept any push
-without a signature, so the first member can be pushed in.
-Once at least one member ref exists the bootstrap window MUST close and all
-subsequent pushes MUST be signed.
-Revoking every member's keys — leaving the member refs present but all keys
-removed — MUST fail closed, not reopen the bootstrap window.
---
-
-[role="requirement", id="auth.signed-push"]
-.Signed Push Verification
---
-Every push to a repository whose member list is non-empty MUST carry a signed
-push certificate (`git push --signed`).
-The server MUST verify the certificate in a `pre-receive` hook before any ref
-is updated:
-
-. The push certificate nonce status (from `GIT_PUSH_CERT_NONCE_STATUS`) MUST
- be `OK`.
-. The certificate MUST contain an SSH signature (an
- `-----BEGIN SSH SIGNATURE-----` block).
-. The signature MUST verify against at least one authorized member key via
- `ssh-keygen -Y verify -n git`.
-. The trust set fed to `ssh-keygen` MUST be the live member set minus any
- revoked fingerprints.
-
-A push that fails any check MUST be rejected before any ref is updated.
---
-
-[role="requirement", id="auth.nonce"]
-.Nonce Configuration
---
-The server MUST configure `receive.certNonceSeed` on every `git http-backend`
-invocation when authentication is active.
-The server MUST configure `receive.certNonceSlop = 60` to tolerate the
-round-trip latency inherent in smart-HTTP, where the nonce is issued and
-verified by two separate `receive-pack` processes.
---
-
-[role="requirement", id="auth.client-setup"]
-.Client-Side Setup
---
-A CLI command (`git ents members setup`) MUST configure the client's local or
-global git config to sign pushes with an SSH key, using the key named by
-`user.signingkey` or a default `~/.ssh/id_ed25519`.
---
docs/spec/compat.adoc
@@ -1,65 +1,0 @@
-== Compatibility
-
-[role="requirement", id="compat.git"]
-.Git Tooling
---
-The server MUST invoke `git` (including `git http-backend`, `git receive-pack`,
-`git archive`, `git cat-file`, `git for-each-ref`, `git symbolic-ref`, and
-`git init --bare`) as external subprocesses.
-Git MUST be present on `PATH` at runtime.
-Git config overrides MUST be passed via `GIT_CONFIG_COUNT` /
-`GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` environment variables rather than
-`git -c`, so they reach the `receive-pack` and `pre-receive` processes that
-`git http-backend` spawns, not merely the CGI wrapper process.
---
-
-[role="requirement", id="compat.ssh-keygen"]
-.OpenSSH `ssh-keygen`
---
-Push certificate verification and browser sign-in verification MUST use
-`ssh-keygen -Y verify` against an `allowed_signers` file written at runtime.
-`ssh-keygen` (OpenSSH) MUST be present on `PATH` at runtime.
-The `allowed_signers` format used MUST be the OpenSSH format: one line per key,
-with a principal column, optional comma-joined options (`cert-authority`,
-`valid-after`, `valid-before`, `namespaces`), and the key material.
---
-
-[role="requirement", id="compat.openssh-signed-push"]
-.Signed Push Protocol
---
-Push signatures MUST use OpenSSH (`gpg.format = ssh`) push certificates.
-The server MUST read the pushed certificate's object ID from `GIT_PUSH_CERT`
-and its nonce status from `GIT_PUSH_CERT_NONCE_STATUS` — the environment
-variables git populates for the `pre-receive` hook — and MUST NOT assume any
-other delivery mechanism.
---
-
-[role="requirement", id="compat.sprite"]
-.Sprite CLI
---
-Asynchronous checks MUST be executed via the `sprite` CLI
-(`sprite auth setup`, `sprite create`, `sprite exec`).
-The `sprite` binary MUST be present on `PATH` at runtime when checks are
-configured.
-The `sprite` CLI MUST be initialized per-push via `sprite auth setup --token`
-from the `SPRITES_TOKEN` environment variable, since the CLI persists
-credentials to a config file and does not read the token per invocation.
---
-
-[role="requirement", id="compat.cgi"]
-.CGI Protocol
---
-`git http-backend` is a CGI program.
-The server MUST populate the standard CGI environment variables (`PATH_INFO`,
-`QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE`, `CONTENT_LENGTH`,
-`GIT_PROJECT_ROOT`, `GIT_HTTP_EXPORT_ALL`) before spawning it.
-The server MUST parse the CGI response format (header block, `\r\n\r\n`
-separator, body) and translate it into an HTTP response.
---
-
-[role="requirement", id="compat.edition"]
-.Rust Edition
---
-All crates MUST target Rust edition 2024.
-The workspace MUST NOT be published to crates.io (`publish = false`).
---
docs/spec/config.adoc
@@ -1,11 +1,0 @@
-== Repository Configuration
-
-[role="requirement", id="config.ref"]
-.Configuration Ref
---
-A repository's loose metadata MUST be stored at `refs/meta/config` as a
-`Config` document with fields: `description`, `homepage`, and `topics`.
-Keeping metadata on a meta ref means a push of ordinary content cannot rewrite
-it, and the metadata carries its own independent history.
-An absent ref MUST yield the default (all empty / zero).
---
docs/spec/deployment.adoc
@@ -1,19 +1,0 @@
-== Deployment
-
-[role="requirement", id="deploy.fly"]
-.Fly.io Deployment
---
-The server MUST be deployable to Fly.io.
-Fly configuration MUST live at `.config/fly.toml` and all `fly`/`flyctl`
-invocations MUST pass `-c .config/fly.toml`.
---
-
-[role="requirement", id="deploy.health"]
-.Health Check
---
-The server MUST expose a liveness probe at `GET /healthz` that returns `200`
-with body `ok` without touching any git repository, so the platform can route
-traffic before any repository exists.
-`GET /` itself is served by the web UI as the repository index
-(<<web.index>>), not the probe.
---
docs/spec/issues.adoc
@@ -1,27 +1,0 @@
-== Issues
-
-[role="requirement", id="issues.ref"]
-.Issue Documents
---
-Each issue MUST be stored at `refs/meta/issues/<id>` as an `Issue` document
-with fields: `title`, `body`, `state` (`open` or `closed`), `labels` (plain
-strings, no separate registry), `author`, and `id` (the friendly number
-described below).
-One ref per issue keeps issues independently loadable and separately historied;
-the ref's commit chain is the issue's edit history.
---
-
-[role="requirement", id="issues.id"]
-.Issue Identity
---
-An issue's stable identifier MUST be a content hash — the object id of the
-originating object, or of the issue's own initial content when it has none
-upstream — and MUST be the issue ref's last path segment, never renamed.
-This identifier is conflict-free and requires no counter.
-An issue MAY additionally carry a friendly sequential number, assigned only
-when a maintainer promotes it; before promotion this number is absent.
-Only promotion advances the number counter, so filing an issue never
-contends it.
-Any future cross-referencing feature (comments, reviews, and the like) MUST
-key off the stable content-hash identifier, not the friendly number.
---
docs/spec/members.adoc
@@ -1,79 +1,0 @@
-== Members
-
-[role="requirement", id="members.ref"]
-.Member Refs
---
-The push trust root MUST be the set of refs matching `refs/meta/member/*`.
-Each ref, `refs/meta/member/<username>`, holds one `Member` document for the
-person named by the ref's last segment.
-The set is decomposed — one ref per person — so adding, refreshing, or revoking
-a member is an independent, separately-historied operation.
---
-
-[role="requirement", id="members.trust"]
-.Member Trust Modes
---
-A member's trust MUST rest on exactly one of three mutually exclusive bases:
-
-Keys::
- A set of leaf signing keys, mapping each fingerprint to its OpenSSH public
- key.
- The solo and small-team default.
-
-Certificate Authority::
- A pinned certificate authority's OpenSSH public key.
- Any certificate the CA issues for the member's principal, within the
- certificate's own validity window, is trusted.
- The enterprise option: rotation, expiry, and new devices require no edit to
- the member ref.
-
-WebAuthn::
- A set of passkey credentials in CASE form, keyed by credential ID, each with
- a human-readable label. WebAuthn credentials authorize browser sign-in only
- and MUST NOT produce `allowed_signers` lines or authorize git push.
---
-
-[role="requirement", id="members.provenance"]
-.Member Provenance
---
-Every member MUST carry a `provenance` recording whether they were
-admin-registered or self-attested via web onboarding. A member ref written
-before this field existed MUST load as admin-registered. A self-attested
-member MUST be granted limited trust: the web service MUST refuse their
-writes outside the allowed set — issues and comments (<<web.comments>>) —
-and they MUST NOT be trusted for signed git push, until an admin promotes
-them.
---
-
-[role="requirement", id="members.account"]
-.Account Link
---
-A member MAY carry an optional `account` field `@`-mentioning their account
-repository by its genesis identity (<<account.genesis>>), so the link
-survives the account repository moving or being renamed.
-An absent field means no account is linked.
---
-
-[role="requirement", id="members.window"]
-.Trust Window
---
-A member MAY carry an optional `valid-after` / `valid-before` window, expressed
-as OpenSSH timestamps (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`).
-An un-refreshed member whose window has lapsed MUST stop authorizing new pushes
-— stale trust fails closed.
-A previously-valid push MUST remain verifiable forever by pinning the
-verification time to the push date via `ssh-keygen -Y verify -Overify-time`.
---
-
-[role="requirement", id="members.allowed-signers"]
-.Allowed Signers Rendering
---
-The server MUST render the live member set as an OpenSSH `allowed_signers` file
-for `ssh-keygen -Y verify`.
-The principal column MUST be a wildcard (`*`) so any email matches.
-Each member's validity window MUST be rendered as comma-joined options
-(`valid-after`, `valid-before`).
-Each leaf key MUST appear on its own line; a pinned CA MUST appear as a
-`cert-authority` line.
-All lines MUST carry `namespaces="git"`.
---
docs/spec/meta-ref.adoc
@@ -1,0 +1,286 @@
+== The Meta-Ref
+
+One entity per ref under `refs/meta/*`.
+The meta-ref is simultaneously the unit of storage, sync, authorization, and
+history; the typed tree (a `Facet` struct mapped to a git tree) is what lives
+behind it.
+The entities below are all instances of this one abstraction.
+
+[role="requirement", id="storage.bare"]
+.Persistent Bare Repository
+--
+Repository state MUST be persisted in a bare Git repository on durable
+storage, created automatically on the first push to any previously unused
+name and never deleted by the server.
+--
+
+[role="requirement", id="storage.meta-ref"]
+.Meta-Ref Documents
+--
+All structured server-side state (members, configuration, checks, run
+results, issues, comments, account profiles) MUST be stored as typed
+documents on dedicated `refs/meta/*` refs, one ref per document or per
+entity, using the `facet-git-tree` serialization: each document becomes a git
+tree, wrapped in a commit parented on the ref's prior tip, so every write is
+a fast-forward and every ref's commit chain is the document's full history.
+
+A document's `Facet` shape IS its on-disk format.
+An incompatible change (a renamed field, a changed field type) silently
+breaks reading data already on a ref.
+Each document type MUST carry a load test against a hand-built fixture in
+the exact on-disk layout.
+--
+
+[role="requirement", id="storage.concurrency"]
+.Concurrent Writes
+--
+Concurrent writes to the same `refs/meta/*` ref MUST use compare-and-swap.
+When a write finds the ref has moved since it was read, the writer MUST
+attempt a structural three-way merge of the document (base / ours / theirs):
+non-overlapping changes — different fields, or different entries of a
+collection — MUST merge cleanly.
+A genuine conflict (the same scalar changed two different ways) MUST fail
+cleanly so the caller can reload and reapply.
+Data MUST NEVER be silently overwritten and a real conflict MUST NEVER be
+silently resolved by picking a winner.
+An in-place state advance (a run's progression) is a deliberate replace and
+MUST fail cleanly on a race rather than merge.
+--
+
+=== Invariants
+
+How `git-store` (the crate implementing <<storage.meta-ref>> and
+<<storage.concurrency>>) makes the storage invariants explicit in code, so
+the next module reuses them instead of re-inventing them.
+Documented, not mandated.
+
+Key strategy::
+ Every document is one of three shapes.
+ A *singleton* lives on one fixed ref (`config`, `account`, `checks`,
+ `revoked`, `issue-number`): `Store::load`/`store`.
+ A *named collection* is one-ref-per-item under a namespace prefix
+ (`member/<username>`, `toolchains/<name>`) or a scalar-keyed map on a
+ single ref (`checks/<name>`, `revoked/<fingerprint>`,
+ `runs/<commit>/results/<name>`): `Store::load_item`/`store_item`,
+ `load_map`/`store_map`.
+ A *content-addressed* collection is keyed by the hash of its own content
+ (`issues/<id>`, `comments/<id>`) via the shared `git_store::new_id`
+ (origin-or-content-hash), so filing an item never contends a counter and
+ the identity rule cannot drift between collections.
+
+Raw trees::
+ A field holding an arbitrary directory (a toolchain's `src`, or an
+ embedded `bin`) is a `facet_git_tree::RawTree` — a passthrough wrapping an
+ already-written tree's object id.
+ `Store::store_tree`/`ref_tree` write and read the document's root tree
+ directly, since such a subtree must exist in the object database before
+ the document referencing it can be assembled.
+
+Authored collections::
+ A collection whose documents treat the commit as the record
+ (`comments/<id>`) writes through `Store::store_item_authored`, stamping
+ the acting human on the ref's commit, and reads authorship back through
+ `Store::provenance`/`item_provenance`.
+ Neither an author nor a timestamp field is duplicated into the document
+ tree.
+
+Collection-key safety::
+ `git_store::ref_segment_ok` is the one place a collection key is checked
+ before it becomes a ref path segment or tree entry name: 1-64 ASCII
+ alphanumerics, `.`, `_`, `-`, or `:`, never starting with `.`, never
+ containing `/`.
+ `Store::store_item`, `store_keyed`, and `store_map` all enforce it,
+ failing with `Error::InvalidKey`.
+
+Domain validation::
+ A type carrying an invariant the type system cannot express (a validity
+ window must not be inverted) exposes its own `validate` and calls it from
+ its own `store`, returning `git_store::Error::Invalid` — for every caller,
+ not just the CLI command that builds the value today.
+
+Closed sets are enums::
+ A value the spec enumerates (`Issue.state`, a run's status) is a
+ facet-derived enum, not a `String`, so an invalid value cannot be
+ constructed.
+
+=== Members
+
+[role="requirement", id="members.ref"]
+.Member Refs
+--
+The push trust root MUST be the set of refs matching `refs/meta/member/*`;
+each ref `refs/meta/member/<username>` holds one `Member` document for the
+person named by the ref's last segment.
+The set is decomposed — one ref per person — so adding, refreshing, or
+revoking a member is an independent, separately-historied operation.
+--
+
+[role="requirement", id="members.trust"]
+.Member Trust Modes
+--
+A member's trust MUST rest on exactly one of three mutually exclusive bases:
+
+Keys::
+ A set of leaf signing keys, mapping each fingerprint to its OpenSSH public
+ key.
+ The solo and small-team default.
+
+Certificate Authority::
+ A pinned certificate authority's OpenSSH public key; any certificate the
+ CA issues for the member's principal, within the certificate's own
+ validity window, is trusted.
+ Rotation, expiry, and new devices require no edit to the member ref.
+
+WebAuthn::
+ A set of passkey credentials in CASE form, keyed by credential ID, each
+ with a human-readable label.
+ WebAuthn credentials authorize browser sign-in only and MUST NOT produce
+ `allowed_signers` lines or authorize git push.
+--
+
+[role="requirement", id="members.provenance"]
+.Member Provenance
+--
+Every member MUST carry a `provenance` recording whether they were
+admin-registered or self-attested via web onboarding; a member ref written
+before this field existed MUST load as admin-registered.
+A self-attested member MUST be granted limited trust: the web service MUST
+refuse their writes outside issues and comments (<<web.comments>>), and they
+MUST NOT be trusted for signed git push, until an admin promotes them.
+--
+
+[role="requirement", id="members.account"]
+.Account Link
+--
+A member MAY carry an optional `account` field `@`-mentioning their account
+repository by its genesis identity (<<account.genesis>>), so the link
+survives the account repository moving or being renamed.
+An absent field means no account is linked.
+--
+
+[role="requirement", id="members.window"]
+.Trust Window
+--
+A member MAY carry an optional `valid-after` / `valid-before` window,
+expressed as OpenSSH timestamps (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`).
+An un-refreshed member whose window has lapsed MUST stop authorizing new
+pushes — stale trust fails closed.
+A previously-valid push MUST remain verifiable forever by pinning the
+verification time to the push date via `ssh-keygen -Y verify -Overify-time`.
+--
+
+[role="requirement", id="members.allowed-signers"]
+.Allowed Signers Rendering
+--
+The server MUST render the live member set as an OpenSSH `allowed_signers`
+file for `ssh-keygen -Y verify`: the principal column a wildcard (`*`), each
+member's validity window as comma-joined options (`valid-after`,
+`valid-before`), each leaf key on its own line, a pinned CA as a
+`cert-authority` line, and every line carrying `namespaces="git"`.
+--
+
+=== Revocations
+
+[role="requirement", id="revocations.ref"]
+.Revocation List
+--
+A single ref, `refs/meta/revoked`, MUST hold the revocation list: a map from
+fingerprint to a free-text reason.
+This is the "faster than expiry" override: the server MUST subtract every
+revoked fingerprint from the trust set before verifying a push.
+--
+
+[role="requirement", id="revocations.ca"]
+.CA Revocation
+--
+Revoking a certificate authority MUST be done by removing the CA member's
+ref.
+A CA is named by its ref, not a fingerprint, so the revocation list operates
+only on leaf-key fingerprints.
+--
+
+=== Account
+
+[role="requirement", id="account.ref"]
+.Account Profile
+--
+A repository becomes an account repository by carrying a `refs/meta/account`
+ref.
+The `Account` document at that ref MUST hold: `username`, `display_name`,
+`bio`, and `created_at` (seconds since the Unix epoch).
+The username is authoritative in the document; the repository path is
+convention, not trust.
+--
+
+[role="requirement", id="account.genesis"]
+.Account Identity
+--
+An account's stable, path-independent identity MUST be the content hash of
+the first `Account` document ever recorded on its ref — fixed at creation,
+so later profile edits never change it and a reference to the account
+(<<members.account>>) survives the account repository moving.
+The identity MUST be derived from the ref's history, never stored as a
+field.
+--
+
+=== Config
+
+[role="requirement", id="config.ref"]
+.Configuration Ref
+--
+A repository's loose metadata MUST be stored at `refs/meta/config` as a
+`Config` document with fields: `description`, `homepage`, and `topics`.
+An absent ref MUST yield the default (all empty).
+Metadata on a meta ref means a content push cannot rewrite it, and it
+carries its own independent history.
+--
+
+=== Issues
+
+[role="requirement", id="issues.ref"]
+.Issue Documents
+--
+Each issue MUST be stored at `refs/meta/issues/<id>` as an `Issue` document
+with fields: `title`, `body`, `state` (`open` or `closed`), `labels` (plain
+strings, no separate registry), `author`, and `id` (the friendly number
+below).
+One ref per issue keeps issues independently loadable and separately
+historied; the ref's commit chain is the issue's edit history.
+--
+
+[role="requirement", id="issues.id"]
+.Issue Identity
+--
+An issue's stable identifier MUST be a content hash — the object id of the
+originating object, or of the issue's own initial content when it has none
+upstream — and MUST be the issue ref's last path segment, never renamed.
+An issue MAY additionally carry a friendly sequential number, assigned only
+when a maintainer promotes it; only promotion advances the number counter,
+so filing an issue never contends it.
+Any cross-referencing feature MUST key off the stable content-hash
+identifier, not the friendly number.
+--
+
+=== Comments
+
+[role="requirement", id="comments.ref"]
+.Comment Documents
+--
+Each code comment MUST be stored at `refs/meta/comments/<id>` as a `Comment`
+document with fields: `body`, `anchor` (<<comments.anchor>>), and an
+optional `issue` cross-referencing an issue by its genesis id
+(<<issues.id>>); an absent `issue` means a free-standing comment.
+The identifier MUST follow the same genesis-key rule as an issue and is
+never renamed.
+One ref per comment keeps comments independently loadable and separately
+historied; the ref's commit chain is the comment's edit history.
+--
+
+[role="requirement", id="comments.authorship"]
+.Authorship From the Commit Chain
+--
+A comment's author and timestamps MUST NOT be stored in the document tree.
+The creator is the author of the ref's first commit and the last editor is
+the author of its tip commit; both MUST be recovered from the commit chain
+at read time.
+--
docs/spec/namespace.adoc
@@ -1,31 +1,0 @@
-== Namespace
-
-[role="requirement", id="namespace.url"]
-.Service URL
---
-The service URL MUST be `git-ents.sh/git-ents`.
---
-
-[role="requirement", id="namespace.path"]
-.Repository Path Validation
---
-A repository path MUST consist of one to three segments, each drawn from a
-conservative character set: ASCII alphanumerics plus `.`, `_`, and `-`.
-No segment may be empty, begin with `.`, or contain a path separator.
-A path that would escape the data directory, nest inside an existing
-repository, or collide with a namespace directory that is not a bare repository
-MUST be rejected before `git http-backend` is invoked.
---
-
-[role="requirement", id="namespace.auto-create"]
-.Automatic Repository Creation
---
-Pushing to a previously unused name MUST create a new bare repository,
-initialized with `http.receivepack = true` so smart-HTTP pushes are accepted.
-Two concurrent first pushes to the same name MUST NOT both initialize the same
-repository: creation MUST be serialized behind a per-server lock.
-After a successful push, if `HEAD` points at a branch that does not exist, the
-server MUST update `HEAD` to point at the pushed branch, preferring `main`,
-then `master`, then the first available branch, so a fresh clone always checks
-out content.
---
docs/spec/nonfunctional.adoc
@@ -1,59 +1,0 @@
-== Nonfunctional
-
-[role="requirement", id="nonfunctional.push-latency"]
-.Push Latency
---
-A push MUST NOT block on check execution.
-The `post-receive` hook MUST return as soon as job files are written to the
-queue, so the client's `git push` connection is released before any check runs.
---
-
-[role="requirement", id="nonfunctional.memory-cap"]
-.Per-Request Memory Cap
---
-No single web request MAY read more than 2 MiB of git object data into memory
-for rendering.
-Blobs, diffs, and other rendered output that exceed this limit MUST be
-truncated and the UI MUST display a notice rather than an error.
---
-
-[role="requirement", id="nonfunctional.concurrency"]
-.Concurrency Model
---
-The HTTP server MUST handle concurrent requests without blocking the async
-runtime on synchronous work.
-Check jobs MUST be run on blocking threads (off the async executor) so a
-long-running check cannot starve unrelated HTTP handlers.
-The request body MUST be written to `git http-backend`'s stdin concurrently
-with draining its stdout; a sequential write-then-read would deadlock when
-`receive-pack` streams progress to the client before it has consumed the full
-pack.
---
-
-[role="requirement", id="nonfunctional.no-panic"]
-.No Panics
---
-The implementation MUST NOT use `unwrap`, `expect`, unchecked indexing/slicing,
-or other constructs that can panic in production code paths.
-Any suppression of a panic-prevention lint MUST be accompanied by a documented
-reason (`#[expect(..., reason = "...")]`); silent `#[allow(...)]` attributes are
-forbidden.
---
-
-[role="requirement", id="nonfunctional.no-unsafe"]
-.No Unsafe Code
---
-The implementation MUST NOT contain any `unsafe` code blocks.
---
-
-[role="requirement", id="nonfunctional.object-store"]
-.Durable Object Store Reads
---
-Meta-ref documents MUST be read and written against the repository's common
-object directory rather than any quarantine overlay.
-Inside a `pre-receive` or `post-receive` hook, git points the per-object-path
-environment variables at a receive-pack quarantine that holds only the incoming
-pack; the durable store, where meta-refs live, is the common directory.
-All meta-ref access MUST open the object database at the common directory
-explicitly so it is not accidentally limited to the quarantine.
---
docs/spec/protocol.adoc
@@ -1,34 +1,0 @@
-== Protocol
-
-[role="requirement", id="protocol.git"]
-.Git Remote Compatibility
---
-The server MUST function as a full Git remote, supporting clone, fetch, and
-push via the standard Git smart-HTTP pack protocol (`git-upload-pack` and
-`git-receive-pack`) without any special client configuration.
-The implementation MUST delegate the git wire protocol to `git http-backend`
-running as a CGI subprocess, translating between HTTP requests and the CGI's
-stdin/stdout.
---
-
-[role="requirement", id="protocol.routing"]
-.Request Routing
---
-A single HTTP listener MUST serve both the git wire protocol and the browser
-web UI on the same port.
-Requests are routed by inspecting the path and query:
-
-* Smart-HTTP service requests (`/info/refs?service=...`,
- `/git-upload-pack`, `/git-receive-pack`) and dumb-HTTP object paths
- (`/objects/...`) MUST be forwarded to `git http-backend`.
-* Browser tree/blob/commit browse paths (`/tree/`, `/blob/`, `/commit/`)
- MUST be served by the web UI, even when a file path within them resembles a
- dumb-HTTP git path (e.g. a file named `HEAD` or a directory named `objects`).
-* Reserved top-level paths (`/login`, `/_debug`, `/healthz`) are served by
- the web UI and shadow a repository of the same name.
-* All other GET requests MUST be served by the web UI.
-* POST requests to the git smart-HTTP RPC paths
- (`/git-upload-pack`, `/git-receive-pack`) MUST be forwarded to
- `git http-backend`.
- All other POST requests MUST be handled by the web UI.
---
docs/spec/revocations.adoc
@@ -1,19 +1,0 @@
-== Revocations
-
-[role="requirement", id="revocations.ref"]
-.Revocation List
---
-A single ref, `refs/meta/revoked`, MUST hold the revocation list: a map from
-fingerprint to a free-text reason.
-This is the "faster than expiry" override: the server MUST subtract every
-revoked fingerprint from the trust set before verifying a push, so a
-compromised key is refused the moment it is listed.
---
-
-[role="requirement", id="revocations.ca"]
-.CA Revocation
---
-Revoking a certificate authority MUST be done by removing the CA member's ref.
-A CA is named by its ref, not by a fingerprint, so the revocation list operates
-only on leaf-key fingerprints.
---
docs/spec/server.adoc
@@ -1,0 +1,214 @@
+== The Server
+
+`git-ents-server` is a library first: the standalone binary and
+`git ents server` are the same code, and the git hooks are its subcommands.
+Anyone who can run the CLI can run the forge.
+
+[role="requirement", id="server.embeddable"]
+.Embeddable Server
+--
+The server MUST be usable as a library: `git ents server` and the
+standalone `git-ents-server` binary MUST run the same code, and the
+`pre-receive` / `post-receive` hooks MUST be subcommands of the server, not
+separate programs.
+--
+
+[role="requirement", id="protocol.git"]
+.Git Remote Compatibility
+--
+The server MUST function as a full Git remote, supporting clone, fetch, and
+push via the standard Git smart-HTTP pack protocol without any special
+client configuration.
+The implementation MUST delegate the git wire protocol to
+`git http-backend` running as a CGI subprocess.
+--
+
+[role="requirement", id="protocol.routing"]
+.Request Routing
+--
+A single HTTP listener MUST serve both the git wire protocol and the
+browser web UI on the same port, routed by path and query:
+
+* Smart-HTTP service requests (`/info/refs?service=...`,
+ `/git-upload-pack`, `/git-receive-pack`) and dumb-HTTP object paths
+ (`/objects/...`) MUST be forwarded to `git http-backend`; so MUST POST
+ requests to the smart-HTTP RPC paths.
+* Browser tree/blob/commit browse paths (`/tree/`, `/blob/`, `/commit/`)
+ MUST be served by the web UI, even when a file path within them
+ resembles a dumb-HTTP git path (a file named `HEAD`, a directory named
+ `objects`).
+* Reserved top-level paths (`/login`, `/_debug`, `/healthz`) are served by
+ the web UI and shadow a repository of the same name.
+* All other requests MUST be served by the web UI.
+--
+
+=== Namespace
+
+[role="requirement", id="namespace.url"]
+.Service URL
+--
+The service URL MUST be `git-ents.sh/git-ents`.
+--
+
+[role="requirement", id="namespace.path"]
+.Repository Path Validation
+--
+A repository path MUST consist of one to three segments, each drawn from
+ASCII alphanumerics plus `.`, `_`, and `-`; no segment may be empty, begin
+with `.`, or contain a path separator.
+A path that would escape the data directory, nest inside an existing
+repository, or collide with a namespace directory that is not a bare
+repository MUST be rejected before `git http-backend` is invoked.
+--
+
+[role="requirement", id="namespace.auto-create"]
+.Automatic Repository Creation
+--
+Pushing to a previously unused name MUST create a new bare repository,
+initialized with `http.receivepack = true`.
+Two concurrent first pushes to the same name MUST NOT both initialize the
+same repository: creation MUST be serialized behind a per-server lock.
+After a successful push, if `HEAD` points at a branch that does not exist,
+the server MUST update `HEAD` to the pushed branch — preferring `main`,
+then `master`, then the first available — so a fresh clone always checks
+out content.
+--
+
+=== Compatibility
+
+[role="requirement", id="compat.git"]
+.Git Tooling
+--
+The server MUST invoke `git` (including `git http-backend`,
+`git receive-pack`, `git archive`, `git cat-file`, `git for-each-ref`,
+`git symbolic-ref`, and `git init --bare`) as external subprocesses; git
+MUST be present on `PATH` at runtime.
+Git config overrides MUST be passed via `GIT_CONFIG_COUNT` /
+`GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` rather than `git -c`, so they
+reach the `receive-pack` and `pre-receive` processes that
+`git http-backend` spawns, not merely the CGI wrapper process.
+--
+
+[role="requirement", id="compat.ssh-keygen"]
+.OpenSSH `ssh-keygen`
+--
+Push certificate verification and browser sign-in verification MUST use
+`ssh-keygen -Y verify` (OpenSSH on `PATH` at runtime) against an
+`allowed_signers` file written at runtime, in the OpenSSH format: one line
+per key, with a principal column, optional comma-joined options
+(`cert-authority`, `valid-after`, `valid-before`, `namespaces`), and the
+key material.
+--
+
+[role="requirement", id="compat.openssh-signed-push"]
+.Signed Push Protocol
+--
+Push signatures MUST use OpenSSH (`gpg.format = ssh`) push certificates.
+The server MUST read the pushed certificate's object ID from
+`GIT_PUSH_CERT` and its nonce status from `GIT_PUSH_CERT_NONCE_STATUS` —
+the environment variables git populates for the `pre-receive` hook — and
+MUST NOT assume any other delivery mechanism.
+--
+
+[role="requirement", id="compat.sprite"]
+.Sprite CLI
+--
+Asynchronous checks MUST be executed via the `sprite` CLI (`sprite auth
+setup`, `sprite create`, `sprite exec`), present on `PATH` at runtime when
+checks are configured.
+The CLI MUST be initialized per-push via `sprite auth setup --token` from
+the `SPRITES_TOKEN` environment variable, since it persists credentials to
+a config file rather than reading the token per invocation.
+--
+
+[role="requirement", id="compat.cgi"]
+.CGI Protocol
+--
+`git http-backend` is a CGI program.
+The server MUST populate the standard CGI environment variables
+(`PATH_INFO`, `QUERY_STRING`, `REQUEST_METHOD`, `CONTENT_TYPE`,
+`CONTENT_LENGTH`, `GIT_PROJECT_ROOT`, `GIT_HTTP_EXPORT_ALL`) before
+spawning it, and MUST parse the CGI response format (header block,
+`\r\n\r\n` separator, body) into an HTTP response.
+--
+
+[role="requirement", id="compat.edition"]
+.Rust Edition
+--
+All crates MUST target Rust edition 2024.
+The workspace MUST NOT be published to crates.io (`publish = false`).
+--
+
+=== Deployment
+
+[role="requirement", id="deploy.fly"]
+.Fly.io Deployment
+--
+The server MUST be deployable to Fly.io.
+Fly configuration MUST live at `.config/fly.toml` and all `fly`/`flyctl`
+invocations MUST pass `-c .config/fly.toml`.
+--
+
+[role="requirement", id="deploy.health"]
+.Health Check
+--
+The server MUST expose a liveness probe at `GET /healthz` returning `200`
+with body `ok` without touching any git repository, so the platform can
+route traffic before any repository exists.
+`GET /` itself is the web UI's repository index (<<web.index>>), not the
+probe.
+--
+
+=== Nonfunctional
+
+[role="requirement", id="nonfunctional.push-latency"]
+.Push Latency
+--
+A push MUST NOT block on check execution: the `post-receive` hook MUST
+return as soon as job files are written to the queue.
+--
+
+[role="requirement", id="nonfunctional.memory-cap"]
+.Per-Request Memory Cap
+--
+No single web request MAY read more than 2 MiB of git object data into
+memory for rendering.
+Output exceeding the limit MUST be truncated with a notice, not an error.
+--
+
+[role="requirement", id="nonfunctional.concurrency"]
+.Concurrency Model
+--
+The HTTP server MUST handle concurrent requests without blocking the async
+runtime on synchronous work; check jobs MUST run on blocking threads.
+The request body MUST be written to `git http-backend`'s stdin concurrently
+with draining its stdout — a sequential write-then-read deadlocks when
+`receive-pack` streams progress before consuming the full pack.
+--
+
+[role="requirement", id="nonfunctional.no-panic"]
+.No Panics
+--
+The implementation MUST NOT use `unwrap`, `expect`, unchecked
+indexing/slicing, or other constructs that can panic in production code
+paths.
+Any suppression of a panic-prevention lint MUST carry a documented reason
+(`#[expect(..., reason = "...")]`); silent `#[allow(...)]` is forbidden.
+--
+
+[role="requirement", id="nonfunctional.no-unsafe"]
+.No Unsafe Code
+--
+The implementation MUST NOT contain any `unsafe` code blocks.
+--
+
+[role="requirement", id="nonfunctional.object-store"]
+.Durable Object Store Reads
+--
+Meta-ref documents MUST be read and written against the repository's common
+object directory, never a receive-pack quarantine: inside a `pre-receive`
+or `post-receive` hook, git points the per-object-path environment
+variables at a quarantine holding only the incoming pack.
+All meta-ref access MUST open the object database at the common directory
+explicitly.
+--
docs/spec/signed-push.adoc
@@ -1,0 +1,124 @@
+== The Signed Push
+
+There is exactly one way to mutate a repository, including its metadata: a
+`git push` carrying a signed-push certificate, verified against the member
+refs.
+The web UI has no side door — a browser edit becomes a real push signed by
+the server's own key.
+Auth state is repository state: no sessions database, no tokens table;
+revocation is a ref update.
+
+[role="requirement", id="auth.bootstrap"]
+.Bootstrap Window
+--
+When no `refs/meta/member/*` refs exist the repository MUST accept any push
+without a signature, so the first member can be pushed in.
+Once at least one member ref exists the bootstrap window MUST close and all
+subsequent pushes MUST be signed.
+Revoking every member's keys — leaving the member refs present but all keys
+removed — MUST fail closed, not reopen the bootstrap window.
+--
+
+[role="requirement", id="auth.signed-push"]
+.Signed Push Verification
+--
+Every push to a repository whose member list is non-empty MUST carry a
+signed push certificate (`git push --signed`).
+The server MUST verify the certificate in a `pre-receive` hook before any
+ref is updated:
+
+. The push certificate nonce status (from `GIT_PUSH_CERT_NONCE_STATUS`)
+ MUST be `OK`.
+. The certificate MUST contain an SSH signature (an
+ `-----BEGIN SSH SIGNATURE-----` block).
+. The signature MUST verify against at least one authorized member key via
+ `ssh-keygen -Y verify -n git`.
+. The trust set fed to `ssh-keygen` MUST be the live member set minus any
+ revoked fingerprints.
+
+A push that fails any check MUST be rejected before any ref is updated.
+--
+
+[role="requirement", id="auth.nonce"]
+.Nonce Configuration
+--
+The server MUST configure `receive.certNonceSeed` on every
+`git http-backend` invocation when authentication is active, and
+`receive.certNonceSlop = 60` to tolerate the round-trip latency inherent in
+smart-HTTP, where the nonce is issued and verified by two separate
+`receive-pack` processes.
+--
+
+[role="requirement", id="auth.client-setup"]
+.Client-Side Setup
+--
+A CLI command (`git ents members setup`) MUST configure the client's local
+or global git config to sign pushes with an SSH key, using the key named by
+`user.signingkey` or a default `~/.ssh/id_ed25519`.
+--
+
+=== Web Sign-In
+
+[role="requirement", id="web.auth.challenge"]
+.Challenge–Response Sign-In
+--
+Browser sign-in MUST NOT require a private key to be transmitted.
+The server MUST issue a one-time nonce (challenge); the member MUST sign it
+locally with their web key using SSHSIG under the `git.ents.cloud`
+namespace (distinct from the git push namespace) and paste back the public
+key and signature.
+The server MUST verify the signature against the pasted key, and the key
+against the live member list for the repository being edited, before
+opening a session.
+A challenge MUST expire after 600 seconds and MUST be consumed on first use
+so it cannot be replayed.
+The sign-in page MUST point at `git ents login` (<<cli.login>>) as the
+preferred flow, keeping the manual signing instructions as the fallback.
+--
+
+[role="requirement", id="web.auth.session"]
+.Sessions and CSRF
+--
+A successful sign-in MUST issue a session cookie (`ents_session`) containing
+an unguessable random token.
+Each session MUST carry a separate CSRF token that state-changing POST
+requests MUST echo back.
+Sessions MUST be held only in server memory, storing only the member's
+public key and display label — no private key is ever held or transmitted,
+and no session state is persisted to disk.
+Signing out MUST drop the session from server memory and clear the cookie;
+the sign-out POST MUST itself carry the CSRF token.
+--
+
+[role="requirement", id="web.auth.edit"]
+.Authenticated Settings Edit
+--
+A settings edit MUST be landed as a real `git push --signed` onto
+`refs/meta/config`, signed with the server's own member key, through the
+same `pre-receive` gate a CLI push traverses.
+The commit's author MUST be the signed-in human (resolved from their
+session's public key to their member username); the committer MUST be the
+server identity.
+The edit MUST be staged on a throwaway ref, deleted whether or not the push
+succeeds.
+If the server has no signing key or nonce seed, the Settings tab MUST hide
+the edit controls rather than present a form that cannot succeed.
+--
+
+[role="requirement", id="web.auth.webauthn-onboarding"]
+.Passkey Onboarding
+--
+[NOTE]
+Planned, not yet implemented. `Trust::WebAuthn` and
+`Provenance::SelfAttestedWeb` exist and are exercised (a `WebAuthn` member
+never produces a push line; `require_admin_registered` refuses a
+self-attested member's edit), but no endpoint yet lets a new member create
+that ref by proving a passkey.
+
+A new member MAY onboard without a CLI by proving control of a passkey in
+the browser.
+The server MUST verify the attestation server-side and write the new member
+ref with `provenance` set to self-attested, recording the attestation
+evidence in the member ref's first commit as an audit record.
+The resulting member gets limited trust until an admin promotes them.
+--
docs/spec/storage.adoc
@@ -1,137 +1,0 @@
-== Storage
-
-[role="requirement", id="storage.bare"]
-.Persistent Bare Repository
---
-Repository state MUST be persisted in a bare Git repository on durable storage.
-A bare repository is created automatically on the first push to any previously
-unused name and is never deleted by the server.
---
-
-[role="requirement", id="storage.meta-ref"]
-.Meta-Ref Documents
---
-All structured server-side state (members, configuration, checks, run results,
-issues, comments, account profiles) MUST be stored as typed documents on
-dedicated
-`refs/meta/*` refs, one ref per document or per entity, using the
-`facet-git-tree` serialization: each document becomes a git tree, wrapped in a
-commit parented on the ref's prior tip, so every write is a fast-forward and
-every ref's commit chain is the document's full history.
-
-A document's `Facet` shape IS its on-disk format.
-An incompatible change (a renamed field, a changed field type) silently breaks
-reading data already on a ref.
-Each document type MUST carry a load test against a hand-built fixture in the
-exact on-disk layout to catch a regression at test time rather than in
-production.
---
-
-[role="requirement", id="storage.concurrency"]
-.Concurrent Writes
---
-Concurrent writes to the same `refs/meta/*` ref MUST use compare-and-swap.
-When a write finds the ref has moved since it was read, the writer MUST
-attempt a structural three-way merge of the document (base / ours / theirs):
-non-overlapping changes — different fields, or different entries of a
-collection — MUST merge cleanly.
-A genuine conflict (the same scalar value changed two different ways) MUST
-fail cleanly so the caller can reload and reapply.
-Data MUST NEVER be silently overwritten, and a real conflict MUST NEVER be
-silently resolved by picking a winner.
-An in-place state advance (a run's progression) is a deliberate replace and
-MUST fail cleanly on a race rather than merge.
---
-
-=== Invariants & Abstractions
-
-This section documents, rather than mandates, how `git-store` (the crate
-implementing <<storage.meta-ref>> and <<storage.concurrency>>) makes the
-storage layer's invariants explicit in code instead of leaving them to be
-re-derived per module. It exists so the next module added to `git-ents`
-reuses these instead of re-inventing them.
-
-Key strategy::
- Every meta-ref document falls into one of three shapes. A *singleton* lives
- on one fixed ref (`config`, `account`, `checks`, `revoked`,
- `issue-number`) and is read and written with `Store::load`/`store`. A
- *named collection* is decomposed one-ref-per-item under a namespace prefix,
- keyed by a caller-supplied name (`member/<username>`) or a scalar-keyed map
- on a single ref (`checks/<name>`, `revoked/<fingerprint>`,
- `runs/<commit>/results/<name>`) — `Store::load_item`/`store_item`,
- `load_map`/`store_map`. A *content-addressed* collection is keyed by the
- hash of its own content (`issues/<genesis-hash>`,
- `comments/<genesis-hash>`) via the shared `git_store::new_id`
- (origin-or-content-hash), so filing an item never contends a counter and
- the identity rule cannot drift between collections. `toolchains/<name>`
- (`git-toolchain`) is a named collection like `member/<username>`, and its
- item is a `Facet` document like any other — a `bin`, an optional `src`
- directory tree, an SPDX license expression, a semver version, and a
- target-triple platform. `src` is always captured whole as a
- `facet_git_tree::RawTree`, a raw-passthrough field wrapping an
- already-written tree's object id rather than a directory layout `Facet`
- could model field-by-field. `bin` is a `Bin` enum over two
- representations: `Bin::Embedded`, the same `RawTree` capture, for a
- toolchain with no stable independent origin; or `Bin::Downloaded`, a list
- of `{url, sha256}` components pointing at a distributor's own hosted,
- hash-pinned archives (rust-lang's dist tarballs, for the `rustup` import
- recipe's default) instead of storing the toolchain's bytes at all —
- fetched, sha256-verified, and extracted fresh each time a Sprite activates
- the toolchain or `git ents toolchain export` runs locally, sparing the
- repository the bytes entirely. `license`, `version`, and `platform` are
- plain strings validated against a real parser (`spdx`, `semver`,
- `target-lexicon`) at import time rather than carried as a parsed type,
- since nothing downstream needs more than the canonical string back.
- `Store::store_tree`/`ref_tree` write and read the document's root tree
- directly, since a `src` (or `Bin::Embedded` `bin`) directory underneath
- must be written into the object database before the document that
- references it can be assembled.
-
-Authored collections::
- A collection whose documents treat the commit as the record
- (`comments/<genesis-hash>`) writes through `Store::store_item_authored`,
- which stamps the acting human on the ref's commit, and reads authorship
- back through `Store::provenance`/`item_provenance`: the genesis commit's
- author created the document, the tip commit's author last updated it.
- Neither an author nor a timestamp field is duplicated into the document
- tree.
-
-Collection-key safety::
- `git_store::ref_segment_ok` is the one place a collection key (a username, a
- check name, a colon-form fingerprint, a content hash) is checked before it
- becomes a ref path segment or tree entry name: 1-64 ASCII alphanumerics,
- `.`, `_`, `-`, or `:`, never starting with `.`, never containing `/`.
- `Store::store_item`, `store_keyed`, and `store_map` all enforce it, failing
- with `Error::InvalidKey` rather than silently injecting an extra path
- component or colliding with a sibling entry.
-
-The map-document helper::
- `Store::load_map`/`store_map` is the shared conversion every "named entries
- on one ref" collection needs — a scalar-keyed map document whose flattened
- public item type carries the key once, not the hand-written wrapper
- document (a `Checks { checks: BTreeMap<...> }`-shaped struct) each of
- `checks`, `revocations`, and run outcomes previously declared for itself.
-
-Domain validation::
- A type that carries an invariant the type system cannot express (a
- member's validity window must not be inverted) exposes its own `validate`
- and calls it from its own `store`, returning `git_store::Error::Invalid` —
- distinct from `InvalidKey`, which is about the collection key rather than
- the document's content. This runs for every caller of that `store`
- function, not just the CLI command that happens to build the value today.
-
-Closed sets are enums, not strings::
- `Issue.state`, and a check run's status, are closed sets the specification
- enumerates (<<issues.ref>>, <<checks.outcomes>>); they are modeled as
- `facet`-derived enums (`issues::State`, `checks::Status`) rather than
- `String`, so an invalid value cannot be constructed instead of merely being
- discouraged by a comment.
-
-Format stability::
- Unchanged from <<storage.meta-ref>>: a document's `Facet` shape is its
- on-disk format, and every document type carries a load test against a
- hand-built fixture in the exact on-disk layout. Introducing `load_map` and
- the closed-set enums above changed several documents' on-disk shape
- (`checks/<name>`, `revoked/<fingerprint>`, `runs/<commit>` outcomes, and
- `issues/<id>` state each moved a scalar to a subtree); each carries an
- updated fixture rather than a version negotiation, acceptable pre-1.0.
docs/spec/web-auth.adoc
@@ -1,69 +1,0 @@
-== Web Authentication
-
-[role="requirement", id="web.auth.challenge"]
-.Challenge–Response Sign-In
---
-Browser sign-in MUST NOT require a private key to be transmitted.
-The server MUST issue a one-time nonce (challenge); the member MUST sign the
-nonce locally with their web key using SSHSIG under the `git.ents.cloud`
-namespace (distinct from the git push namespace) and paste back the public key
-and signature.
-The server MUST verify the pasted signature against the pasted key, and the key
-against the live member list for the repository being edited, before opening a
-session.
-A challenge MUST expire after 600 seconds and MUST be consumed on first use so
-it cannot be replayed.
-The sign-in page MUST point at `git ents login` (<<cli.login>>) as the
-preferred flow, keeping the manual signing instructions as the fallback for
-a browser without the CLI installed.
---
-
-[role="requirement", id="web.auth.session"]
-.Sessions and CSRF
---
-A successful sign-in MUST issue a session cookie (`ents_session`) containing an
-unguessable random token.
-Each session MUST carry a separate CSRF token that state-changing POST requests
-MUST echo back, so a cross-site request (which cannot read the cookie) cannot
-act as the signed-in user.
-Sessions MUST be held only in server memory; no session state is persisted to
-disk.
-A session MUST store only the member's public key and display label — no
-private key is ever held or transmitted.
-Signing out MUST drop the session from server memory and clear the session
-cookie; the sign-out POST MUST itself carry the CSRF token, so a cross-site
-request cannot force a sign-out.
---
-
-[role="requirement", id="web.auth.edit"]
-.Authenticated Settings Edit
---
-A settings edit MUST be landed as a real `git push --signed` onto
-`refs/meta/config`, signed with the server's own member key, through the same
-`pre-receive` gate a CLI push traverses.
-The commit's author MUST be the signed-in human (resolved from their session's
-public key to their member username); the committer MUST be the server identity.
-The edit MUST be staged on a throwaway ref and pushed onto `refs/meta/config`
-via the signed push; the staging ref MUST be deleted whether or not the push
-succeeds.
-If the server is not configured with a signing key or nonce seed, the Settings
-tab MUST hide the edit controls rather than present a form that cannot succeed.
---
-
-[role="requirement", id="web.auth.webauthn-onboarding"]
-.Passkey Onboarding
---
-[NOTE]
-Planned, not yet implemented. `Trust::WebAuthn` and `Provenance::SelfAttestedWeb`
-exist and are exercised by `members::allowed_signers` (a `WebAuthn` member never
-produces a push line) and by the web write path (`require_admin_registered`
-refuses a self-attested member's edit), but no endpoint yet lets a new member
-create that ref by proving a passkey. Tracked so this section states an
-intended feature rather than current behavior.
-
-A new member MAY onboard without a CLI by proving control of a passkey in the
-browser. The server MUST verify the attestation server-side and write the new
-member ref with `provenance` set to self-attested, recording the attestation
-evidence in the member ref's first commit as an audit record. The resulting
-member gets limited trust until an admin promotes them.
---