multi: merge branch 'docs/advisory-story'
commit
714cec7multi: merge branch 'docs/advisory-story'
Clarity pass over the local-UI advisory model, the identity/transport- ACL auth layering, and serve-as-web-UI, aligned to the canonical green/orange/red verdict story across faq.adoc, design.adoc, abstractions.adoc, and docs/spec/{gate,sync,receive,roots}.adoc.
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
docs/abstractions.adoc
@@ -106,9 +106,10 @@
* The local store accepts any write and runs the gate as a verdict; failure annotates (*advisory*).
Enforcing the gate locally would destroy offline-first: you could not author while unenrolled or work against an unfetched member list.
-You can always write locally what hosted will never accept; the sync path's answer to a rejected canonical push is an offer to push to your inbox ref instead.
+You can always write locally what hosted will never accept; the moment a verdict predicts rejection — at commit time in the local UI, or at push pre-flight — sync offers to route the commit to your inbox ref instead, not only after an actual rejection.
Three call sites, one function: hosted CAS, local UI verdict, push pre-flight.
+A verdict is never a bare pass/fail: on failure it carries which rule failed and for which refname, so the local UI and pre-flight can render an actionable reason — "your signing key is not authorized for `refs/heads/main`" — instead of an opaque no.
The hosted server is not where policy lives; it is the one place where the verdict has teeth.
The local web UI signs as the user, with the user's own member key, indistinguishable from CLI-authored commits.
@@ -327,8 +328,8 @@
|Purpose |Personal forge, development, demos |Production forge
|===
-Working repos reject pushes to the checked-out branch; the local server sets `receive.denyCurrentBranch=updateInstead` so pushes also update the working tree.
-Known edge: `updateInstead` fails on a dirty worktree, so branch-push behavior is not perfectly identical to hosted mode — metadata behavior is.
+`git ents serve` is the local web UI, not a git-serving transport; the one place a working repo accepts an external branch push is the integration-test harness, for which the local root sets `receive.denyCurrentBranch=updateInstead` so an accepted push also updates the working tree.
+Known edge: `updateInstead` fails on a dirty worktree, so this harness path is not perfectly identical to hosted mode — metadata behavior is.
Worktree update is frontend business, after `receive` accepts; core never touches a worktree.
Pushes to `refs/meta/*` never touch the working tree, so all metadata behaves identically in both modes: the deployment model is an implementation detail of the data model — a direct consequence of abstractions 1 and 5.
docs/design.adoc
@@ -22,6 +22,7 @@
Locally, transfer is vacuous — so the system's real write primitive is the transaction: `receive(refs, objects, events, proposal)`, a library function pure over storage traits.
Every mutation frontend — CLI, local web UI, smart-HTTP — constructs a proposal and calls the same function.
Local and hosted do not share a push path; they share `receive`.
+`git ents serve` is that local web UI: HTML over loopback, an implementation detail, signing every edit in-process with the user's own key exactly like the CLI does.
Mutations are author-signed commits, not push certificates.
A push cert signs a transition and evaporates at the transport layer; a commit signature replicates with the repo and verifies in every clone, forever.
@@ -31,6 +32,7 @@
Because writing and verifying are separated, local is genuinely offline-first: the local store accepts any write and the gate merely annotates.
You can author while unenrolled, work against an unfetched member list, and accumulate meta-refs the canonical store would reject.
The gate is the same pure function everywhere; only its consequence differs — advisory locally, mandatory at the hosted CAS.
+Every advisory verdict, whether rendered in the local UI the moment you commit or at push pre-flight, is a prediction of that same hosted outcome, computed offline against policy as of your last fetch — it can go stale, but it is never wrong about the rules it has.
'''''
@@ -38,6 +40,8 @@
A commit signature proves authorship; refname rules prove placement; the two are deliberately distinct.
In the normal case one person is both, and the tip invariant — every meta-ref tip is signed by a member authorized for that refname — is checkable after the fact by anyone with a clone.
+The two layers keep strict vocabulary: identity is commit signing, the sole mechanism locally and what the gate evaluates for meta-ref admission everywhere; a transport ACL — a push cert, a connection credential — decides only who may connect and update `refs/heads/*`, and never carries meta-ref admission semantics.
+The same refname-rule data feeds both layers, enforced at the transport door for code refs and at the gate for meta-refs, which is why a local verdict predicts both uniformly.
When author and placer differ, the mechanism is adoption: an authorized member merges the contributor's signed commit onto the canonical ref.
The merge satisfies the tip invariant; the contributor's signature survives intact in ancestry.
docs/faq.adoc
@@ -192,8 +192,12 @@
Everything writes locally; the gate never blocks a local write.
Policy is repository state, so the gate evaluates the actual canonical rules offline, staleness bounded by your last fetch.
-*I want to know before pushing whether my push will be accepted.*
-The push pre-flight runs the same gate function the hosted store runs at CAS time; it is a prediction that can only be stale, never wrong about the rules.
+*I fire up the local UI — what do the green and red verdicts actually mean?*
+Say policy lets you write `joey/*` and `refs/meta/issue/*`.
+Commit to an issue, or to `joey/*`, and the UI shows green.
+Commit to `main` and it shows orange or red — "when you push, the server will reject this: your signing key is not authorized for `refs/heads/main`" — with an offer to send it to your inbox instead.
+Nothing was ever blocked locally: every verdict, in the UI or at push pre-flight, is a prediction of the same gate function the hosted store runs, evaluated against the policy your last fetch brought down.
+That prediction can go stale but is never wrong about the rules it has, and the inbox offer appears the moment a verdict turns red, not only after an actual rejection.
*I want to understand what the hosted server actually is.*
A store with a mandatory gate and a durable queue: custody of canonical refs, admission with teeth, and effect dispatch.
@@ -248,9 +252,10 @@
*I want to know what happens when someone force-pushes a branch my effect watches.*
Trigger sets are monotone and entry-only: a commit leaving the set retracts nothing, because results are immutable history — it simply stops being an obligation.
-*I want to push to a checked-out branch on my local server.*
-Local serving sets `receive.denyCurrentBranch=updateInstead`, so accepted pushes also update the working tree.
-Known edge: `updateInstead` fails on a dirty worktree, so branch pushes are not perfectly identical to hosted mode — metadata pushes are, because `refs/meta/*` never touches a worktree.
+*How does the integration-test harness push into a live working tree?*
+Signed writes go through the same in-process `receive()` as everything else; there is no local git-serving transport as a product feature.
+Only the test harness pushes objects into a checked-out repo from outside, and for that narrow case it sets `receive.denyCurrentBranch=updateInstead`, so an accepted push also updates the working tree.
+Known edge: `updateInstead` fails on a dirty worktree, so this never affects `refs/meta/*`, which doesn't touch a worktree at all.
*I want to script against the forge from my editor or CI.*
`git-ents-server` is a library first; the serve command and binaries are thin wrappers, and the primitives (`git store`, `git anchor`, `git comment`, `git effect`, `git toolchain`) are plumbing over the same library.
docs/spec/gate.adoc
@@ -89,6 +89,10 @@
annotating the result; a failing verdict MUST NOT block the write.
Enforcing the gate locally would destroy offline-first: a user could not
author while unenrolled, or work against an unfetched member list.
+A failing local verdict, computed entirely offline against last-fetched
+policy (<<gate.policy-as-state>>), MUST surface the inbox alternative
+(<<sync.inbox-routing>>) at verdict time, not only once a push is
+actually rejected.
--
[role="requirement", id="gate.call-sites"]
@@ -101,6 +105,18 @@
diverge from the rules the hosted store will actually apply.
--
+[role="requirement", id="gate.verdict-reason"]
+.Verdict Carries a Machine-Readable Reason
+--
+On failure the gate MUST return which requirement
+(<<gate.tip-signed>> through <<gate.atomic-cas>>) failed and the subject
+refname, not a bare pass/fail.
+Every advisory call site (<<gate.advisory-local>>, <<sync.pre-flight>>)
+MUST render this reason to the user — for example, "your signing key is
+not authorized for `refs/heads/main`" — so a negative verdict is
+actionable before a push is ever attempted.
+--
+
=== Adoption
[role="requirement", id="gate.adoption-merge"]
docs/spec/receive.adoc
@@ -28,6 +28,10 @@
authentication.
Gate evaluation (<<receive.unit>>) MUST be checkable against exactly
this shape, independent of which frontend constructed the proposal.
+This transport-auth evidence is a connection-level ACL input for
+`refs/heads/*` (<<gate.principled-split>>) only; gate evaluation for
+meta-ref admission MUST ignore it entirely and MUST NOT consult it in
+place of the tip invariant (<<gate.signature-artifact>>).
--
[role="requirement", id="receive.shared-path"]
docs/spec/roots.adoc
@@ -23,8 +23,9 @@
The `git-ents` CLI's composition root MUST wire: a loose-ref `RefStore`,
the local odb as object store, a Docker `Executor`, a null `EventSink`, and
the advisory gate (<<gate.advisory-local>>).
-`git ents serve` MUST reuse this same wiring, adding only the smart-HTTP
-frontend on loopback.
+`git ents serve` MUST reuse this same wiring, adding only the `ents-web`
+HTTP frontend on loopback — the web UI, never git's smart-HTTP wire
+protocol, which the local root MUST NOT expose.
Local effect execution MUST be pull, via `git effect run`, never a daemon
watching refs; the queue is the only component this root skips
(<<effect.local-run>>).
@@ -79,16 +80,35 @@
remain a supported deployment alongside hosted and local-loopback serving.
--
+[NOTE]
+====
+Example: policy lets Joey write `joey/*` and `refs/meta/issue/*`. He
+starts the local UI (<<roots.web-signing>>). A commit to an issue, or
+to `joey/*`, shows green. A commit to `main` shows orange or red —
+"when you push, the server will reject this: your signing key is not
+authorized for `refs/heads/main`" (<<gate.verdict-reason>>) — with an
+offer to send it to his inbox instead (<<sync.inbox-routing>>).
+Nothing was ever blocked locally: every verdict is a prediction of the
+gate, evaluated against policy as of Joey's last fetch
+(<<gate.policy-as-state>>), rendered by the same in-process signing
+this section specifies — never a separate git-serving transport.
+====
+
[role="requirement", id="roots.worktree-update"]
-.Local Serve and the Working Tree
+.Test-Harness Pushes Into a Working Tree
--
-The `git-ents` local root MUST set `receive.denyCurrentBranch=updateInstead`
-so an accepted branch push also updates the working tree.
+Pushing into a local repository's checked-out branch from outside is
+integration-test-harness capability, not a product-facing feature: the
+local root's user-facing serving surface is the web UI
+(<<roots.web-signing>>), never a git transport.
+For that harness case, the `git-ents` local root MUST set
+`receive.denyCurrentBranch=updateInstead` so an accepted branch push
+also updates the working tree.
Worktree update MUST happen only after `receive` accepts the push, as
frontend business; core code MUST NOT touch a worktree.
-A dirty worktree is a known edge where `updateInstead` fails, so
-branch-push behavior differs from hosted while metadata behavior, which
-never touches a worktree, stays identical.
+A dirty worktree is a known edge where `updateInstead` fails, so this
+harness path differs from hosted while metadata behavior, which never
+touches a worktree, stays identical.
--
[role="requirement", id="roots.web-session"]
docs/spec/sync.adoc
@@ -29,10 +29,13 @@
[role="requirement", id="sync.inbox-routing"]
.Rejection Routes to the Inbox
--
-When pre-flight predicts, or the canonical store actually returns, a
-rejection of a push to a canonical meta-ref, sync MUST offer to route the
-same commit to the author's `refs/meta/inbox/*` ref instead of discarding
-it.
+Any negative advisory verdict against a canonical meta-ref — the local UI
+verdict at commit time (<<gate.advisory-local>>), push pre-flight
+(<<sync.pre-flight>>), or the canonical store's actual rejection — MUST
+cause sync to offer routing the same commit to the author's
+`refs/meta/inbox/*` ref instead of discarding it.
+The offer MUST appear the moment the verdict goes negative, not only
+after a push is actually attempted and refused.
--
[role="requirement", id="sync.divergence-merge"]