#[derive(Facet)]
struct Effect {
trigger: CommitQuery, // a commit set as a function of ref state
toolchains: Vec<ToolchainRef>, // e.g. ["rust-1.88"]
run: Command,
}
docs/abstractions.adoc
git-ents Abstractions
The load-bearing abstractions, stated as invariants; everything else in the project is an instance or a consequence.
Admission rule: an abstraction appears here only if it already carries weight in multiple crates or commands, or closes a gap those abstractions exposed. Where this document runs ahead of shipped code, the gap is marked.
''
The six abstractions
Each answers one question: what data is (1), how data points (2), how state lives (3), what actors assert (4), how the system judges (5), how the system computes (6).
1. Typed tree
A Rust struct annotated #[derive(Facet)] is the storage schema.
facet-git-tree maps struct ↔ Git tree directly; no serialization format exists to version.
Changing a struct is a storage migration, not a refactor — and a migration is itself a signed commit: rewrite the tree under the new struct, commit on the ref’s old tip. History keeps the old encoding as archive. Trees stay pure struct representations — no version marker entry.
Ref-level metadata that is not entity content — who, when, the signature — lives in the commit header, because the commit is already the storage unit; no reserved trailer exists. Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification (see 3).
Tip invariant: the tip of a meta-ref is always readable by the binary that owns the entity type; a non-owning binary treats the tree as opaque and degrades to generic display (git store show); history is archival.
(Redacted entries are the one qualification: readers surface a withheld object as a redaction marker, never an error — see Derived.)
2. Binding
A Binding, homed in the anchor crate (ents-anchor), is the single typed-reference vocabulary into the object graph.
Exactly five variants, each named by what it is bound to:
-
Commit { commit }— history-bound. -
Tree { tree, path }— content-bound. The path is advisory metadata, never identity; identity is the tree oid. -
Delta { base_tree, head_tree, path }— transformation-bound; the same path rule applies. A commit range is evidence for a Delta, never a binding itself. (Interdiff computation — what changed between a reviewed delta and a new one — is future work; Delta today has identity and staleness semantics only.) -
Position { blob, lines, commit }— position-bound and projectable: the anchor, a durable pointer into source.-
Retention invariant: the tree storing a Position embeds the anchored blob, plus a context blob of the surrounding lines, as ordinary entries — content addressing makes this free. The anchored content is reachable from
refs/meta/*and survives force-push, branch deletion, and gc — no gc special-casing, and no pinned ancestry for the position’s own content: the anchored commit’s oid is recorded as data only; embedding, not ancestry, is what retains the content. (Gitlinks are not reachability edges and retain nothing; embedding is the only mechanism that works.) Redaction (see Derived) is the sole deliberate exception to retention. -
Projection: Positions project onto newer commits at read time — blame plus fuzzy matching against the context blob; binding data is never mutated. When the anchored commit has been gc’d, projection degrades to context matching instead of breaking.
-
-
Hybrid { commit, tree }— relational checks: a parent commit plus a body tree.
Reachability witness rule: every binding carries at least one witness commit, and the ledger commit that stores the binding links each witness as a second parent — ordinary git reachability then keeps the bound objects alive.
Content-typed bindings (Tree, Delta) need a witness because a subtree is reachable only through some commit; the rule generalizes the review pin (model.review-pin).
The two retention mechanisms coexist without contradiction: embedding preserves a Position’s content inside the binding’s own tree; witness parents keep bound objects reachable from the ledger commit that stores the binding.
Witness is provenance, not identity: two claims binding the same Tree oid via different witnesses bind the same target.
Validity: revalidate(binding, state) → Valid | Stale | Unknown, judged per variant:
-
Commit — valid iff the commit is reachable from the ref under evaluation.
-
Tree — valid iff the tree oid appears at the recorded (or any) path in the state under evaluation; stale otherwise.
-
Delta — valid iff evaluated against the same
(base_tree, head_tree)pair; stale otherwise. -
Position — the existing projection semantics, unchanged.
Bindings are independent of any consumer; comments use Position, claims bind any variant.
3. Ledger
A ref under refs/meta/* is simultaneously the unit of:
-
Storage — the ref points to a commit whose tree is the entity.
-
Synchronization — fetch or push only the entities you care about.
-
Authorization — refname-keyed rules gate who may advance the ref.
-
History — the ref’s commit history is the audit trail.
Granularity rule: one ref per independently-authored entity (refs/meta/member/*, refs/meta/issues/*, refs/meta/comments/*, refs/meta/effects/*, refs/meta/results/*); one ref for repo-global state (refs/meta/account, refs/meta/config).
Entities that different actors write concurrently must not share a ref.
Writes stay conflict-free; reads aggregate refs into views.
Two namespaces are consequences of the granularity rule plus the gate (5):
-
refs/meta/inbox/*— entities authored by someone not authorized for a canonical ref, awaiting adoption. -
refs/meta/self/<member>/*— results produced by a member’s own executor rather than a designated worker.
Both hold the same typed trees as their canonical counterparts; only the refname rule differs.
Every meta-ref mutation is an author-signed commit. Which artifact carries the authorization evidence is a carrier question internal to the ledger, not an abstraction of its own — and the answer is the commit signature: a data artifact, not a transport artifact. It replicates with the repo and verifies offline in every clone, so verification evidence is itself repository state. Push certificates are demoted to transport concerns; they carry no meta-ref semantics.
A commit signature proves authorship; it does not prove placement. The difference is recovered explicitly:
-
Refname binding — the refname is recomputed from the commit’s own signed content (a genesis oid, a natural-key tree field, a composite of fields and signer) and a mismatch refuses; without this, a signed commit could be replayed as the tip of a different meta-ref.
-
Anti-replay — meta-refs advance fast-forward-only in the DAG sense (the new tip descends from the old), enforced by atomic CAS on the ref store; the parent hash is the freshness binding, so no nonce is needed.
Tip invariant: the tip of a meta-ref is signed by a member authorized for that refname. This is checkable after the fact by anyone with a clone, not only by whoever ran the server.
Adoption, never rewrite: when author and placer differ — an inbox entity accepted by an admin, a user-run result accepted by a maintainer — the authorized member merges the contributor’s commit onto the canonical ref. The merge commit satisfies the tip invariant; the contributor’s signed commit sits in ancestry with attribution cryptographically intact. Cherry-picking creates a new commit object and destroys the author’s signature; it is forbidden as an adoption mechanism. Fast-forwarding directly to a contributor’s commit would put an unauthorized signature at the tip; adoption is always a merge, even a trivial one.
The same merge covers a member’s own divergence: two of your machines can race your single-writer ref, and sync resolves it by merging your own heads rather than erroring. Author and placer are both you, the merge tip descends from the old tip, and the tip invariant holds as written; typed trees merge schema-aware, not textually.
The principled split: content signatures carry authorization only where mutations are author-signed single-writer appends — which the granularity rule guarantees for meta-refs.
On refs/heads/*, pushing commits you did not author is legitimate, so branch refs keep transport-level auth.
"Signed push is the only write path" is therefore retired; the invariant is the tip invariant above, which is strictly stronger where it applies.
Recorded, never erased: ledger history is never rewritten; a state change is recorded as new state, never erased from old — revocation, for instance, is a state on the member entity, not a deletion (see Command surface). Redaction (see Derived) is the sole deliberate exception.
4. Claim
A claim is a kernel ledger entity: signer × binding × verdict, the verdict a small closed enum — affirm, deny, note — plus an opaque kind the kernel never interprets.
Kind vocabularies (review, ci, …) are package policy, never kernel enumeration.
Storage is one ref per claim under refs/meta/claims/<id>, where the id is the genesis commit’s own oid — the standard sign-then-name envelope — honoring the granularity law: claims by different signers never share a ref.
Authorship is the standard signed-commit path; no privileged writers.
The claim’s ledger commit carries its binding’s witness commit(s) as extra parents — the binding reachability rule (2) — reusing the review-pin retention mechanism.
Open question, deliberately unresolved: whether claims subsume effect results.
The results()/meta() query atoms and the result taxonomy (6) are unchanged pending that decision.
(Gap: no shipped feature consumes claims yet; reviews-over-claims is follow-up work.)
5. Gate
Verification is a pure function over ref-store reads:
-
The new tip is signed by a member authorized for this refname.
-
The refname recomputes from the tip’s signed content.
-
The new tip descends from the old tip.
-
The update commits via atomic CAS.
Because members and refname rules live under refs/meta/*, the policy is repository state: any frontend evaluates the actual policy, offline, staleness bounded by last fetch.
Verification epoch: the gate applies the tip invariant from an epoch recorded in refs/meta/config; history before the epoch is archival — the typed-tree stance, applied to verification.
The epoch-setting commit is the first gated tip of the config ref, which resolves the bootstrap circularity of reading the epoch from the very ref the gate verifies.
The gate is a property of the store, not of writing:
-
The hosted store runs the gate at CAS time; failure aborts the transaction (mandatory).
-
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 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. Server-key signing of UI edits is a hosted necessity only (a browser cannot hold the git signing key) and must not be imported locally, where it would be strictly worse provenance.
6. Effect
An effect is a declarative, content-addressed subscription to a commit-set query, whose execution is sandboxed and whose output re-enters the repository as verified data.
It is repository data under refs/meta/effects/<name>:
Results location is derived by convention, never declared by the effect: an effect cannot choose where its verdicts land.
Trigger semantics: the trigger denotes a set of commits; the effect fires once per commit that enters the set. The query algebra is deliberately tiny:
-
rev(expr)— any revspec or ref glob over code refs:refs/heads/*,refs/tags/*,main ^release, ancestry, merge-base. Meta-refs are outside `rev()’s domain by definition. -
results(effect, status)— commits having a result of that status; cheap to resolve because the results refname encodes the tested oid. -
meta(glob)— author-written meta-refs, for consumers like the fanout-index rebuild. It can never match effect-written namespaces (refs/meta/results/*,refs/meta/index/*); those are reachable only throughresults(…). -
Set operations — union, intersection, difference.
RefPattern survives as the degenerate query rev(<glob>); nothing shipped changes meaning.
(Gap: CommitQuery beyond the degenerate case is design, not code.)
Monotone, entry-only: a force-push can shrink a set, but a commit leaving the set retracts nothing — results are immutable history; the commit merely stops being an obligation. Monotone semantics is what makes distributed evaluation safe with no coordination beyond the existing CAS.
No pipeline state: the work set is trigger − results(self, any) — the results ref is the materialization marker.
The dedup key (effect, oid) over an at-least-once queue yields exactly-once outcomes with zero state outside the repository.
Result taxonomy: a result is pass, fail, or error.
Command exit status is always a result — pass or fail means the effect ran.
Infrastructure failure is not a result: it is a queue concern with bounded retry, and only retry exhaustion writes a terminal error result, signed by the worker’s member key like any other.
Retry bounds are deployment configuration, like executor choice — never effect data.
This keeps the work set correct in both directions: a transient outage can neither retry forever nor permanently discharge an obligation.
Recursion is structure, not a rule: downstream-of-effects is syntactically visible — a query names results(…) or it does not.
Closure is opted into by writing the query you mean; because rev() and meta() cannot name an effect-written ref, the accidental fork bomb is unreachable by construction.
Pipeline invariants:
-
post-receiveremains a dumb matcher: a query’s ref footprint is statically extractable, so a transition maps to affected queries, which enqueue re-evaluation. Set entry is computed incrementally from theold..newfrontier, bounded by commit-graph generation numbers. -
Pushes are never blocked; the durable enqueue is the entire synchronous cost.
-
A worker dequeues, materializes declared toolchains from
refs/meta/toolchains/*, and executes in a sandbox — one trait, multiple backends: Fly.io Sprite, Docker. Host-direct exists only behind an explicit--unsandboxed. -
Results return only as signed commits pushed to the effect’s results ref, one ref per tested commit (
refs/meta/results/<effect>/<short-oid>), so concurrent results never conflict.
Identity discipline: the runner is a member, not an ambient authority.
A result signature proves who pushed the result, not that the run was faithful; official results are official because canonical results refs are writable only by designated worker keys — a refname rule, not a runtime property.
Any member may run any effect on their own executor and account; their results land in refs/meta/self/<member>/* or the inbox, adoptable by merge like everything else, with the trust decision explicit in the adoption.
Anyone can run CI; nobody can impersonate the verdict.
Where the line holds: no content predicates beyond results(…, status), no time atoms, no external-event atoms.
A query language that reads file contents is a build system’s dependency scanner in the trigger layer; that work belongs inside effects, not around them.
The bet, stated as a bet: DAG membership plus results membership is a sufficient trigger basis, and composition happens in the repository, not in a workflow language.
The sandbox bounds the runtime blast radius; the admin-only write rule on refs/meta/effects/* bounds who can schedule execution on canonical infrastructure at all.
Hosted mode requires both.
''
The loop
The ledger (3), the gate (5), and the effect (6) close into each other: signed ledger commits are the only way state changes, the gate is the only admission judgment, effects are the only side-effect path — and effect results are signed commits by workers that are just members, judged by the same gate. All state changes, human or machine, flow through one verified, audited channel, and the repository is the message bus. This closure is the design’s central property.
''
Composition (consequence of the abstractions)
No code knows where it is running; the core is handed trait objects and never asks.
Any if hosted branch inside the library is the design failing.
The seams:
-
RefStore— reads plus atomic multi-ref CAS. Local: loose refs written by our code, notgit update-ref, so local mutations honor the same CAS discipline. Hosted: Postgres rows. -
ObjectStore— local: the odb; hosted: Tigris. -
EventSink— where post-receive matches go. Local: null; hosted: a durable queue. -
Executor— Docker, Sprite, or unsandboxed, chosen by whoever built it.
The vocabulary defers to gitoxide wherever gitoxide has one — object access is gix_object::Find/Exists/Write, not a private trait — and new seams exist only where upstream is silent: the pluggable ref store, server-side receive, reachability artifacts.
Crates that extend git rather than the forge carry the gix- prefix, import nothing from the forge, and stay upstream-shaped by construction.
"Push" conflates object transfer with a verified ref transaction; locally, transfer is vacuous.
The unit the library exposes is receive(refs, objects, events, proposal): gate evaluation, effect matching, and enqueue live inside it, above the traits.
Frontends construct a Proposal and call it — the CLI and local UI in-process against the odb, smart-HTTP by unpacking the wire pack into the ObjectStore first.
Local and hosted do not share a push path; they share receive, with only the trait impls swapped.
That is the correctness anchor for writes, parallel to git effect run for execution.
Composition roots are the only place deployment exists — roughly fifty lines each of trait construction and wiring:
-
git-ents(CLI): fileRefStore, odb, Docker executor, nullEventSink, advisory gate.git ents serveis the same wiring plus the smart-HTTP frontend on loopback. Local effect execution is pull (git effect run), never a daemon watching refs; the queue is the only skipped component, and it carries no correctness content. -
git-ents-server(hosted): Postgres, Tigris, durable queue, Sprite executor, mandatory gate. -
The worker — queue consumer, executor, push-back client — is a real seam in the library and a packaging decision in the binary; it stays inside
git-ents-serveruntil scale forces the split, but never shares in-process state with receive.
What to run is repo data; how to run it is a deployment property.
An effect must not be able to demand its own executor or --unsandboxed.
Config selects trait impls at the root and never leaks past it; core code reading config is a trait that should exist and does not.
Store and executor are orthogonal axes, so the matrix has quadrants nobody designs: local store plus Sprite executor is git effect run --executor sprite with your own Fly token, and it works because everything the run needs is repo data.
The honesty test for the seams: a third root — say single-node self-hosting on SQLite, local fs, and Docker — must be constructible without touching the library.
''
Layering (consequence of the abstractions)
The crate graph is four layers, each depending only on the layer(s) before it: substrate, kernel, package, CLI.
Dependencies point one way; a lower layer never depends on a higher one, checked mechanically (layering.rs, run in CI alongside every other test).
-
Substrate —
gix-ref-store: the pluggableRefStoreseam gitoxide itself does not provide. Extends git, not the forge; carries thegix-prefix per the Composition section’s own rule. -
Kernel —
ents-model,ents-anchor,ents-gate,ents-effect,ents-query,ents-receive,ents-sync,ents-testutil. Owns mechanism: typed ref-store access and refs/meta layout primitives, signing and authorship, the entity envelope (typed trees, id assignment), gate execution and composition semantics, effect dispatch, adoption/inbox mechanics, yank, sync/receive plumbing, hooks.ents-model(the entity envelope) depends onents-anchorfor the binding vocabulary — a kernel-internal edge the layering check already admits. The kernel is declarative and generic — it defines how any entity is stored, verified, and run, never which entities exist. -
Package —
ents-forge(issues, comments, review/release/check types as they land),ents-kiln(toolchains). Owns types and policy: the domain entity structs, and any package-specific gate or effect definitions. A package depends on kernel crates freely, and on other packages never —ents-forgeandents-kilndo not depend on each other. -
CLI — the leaf layer, two sibling occupants:
git-entsandents-web, the composition roots and their direct consumers.git-entsdepends on the kernel directly (for kernel-owned commands:setup,members,account,effect,inbox,redact,hook) and on every installed package (forcomment, froments-forge;toolchain, froments-kiln). Mounts each package’s subcommand grammar through one convention (crate::package::Package, ingit-ents): a package owns its own figue action enum, defined in its own crate, and the CLI’s top-level subcommand enum references that type directly — a compile-time pairing, not a runtime plugin registry, because the argument grammar is resolved from a#[derive(Facet)]shape at compile time, with nothing to register dynamically.ents-web(phase 7) is a second leaf, not a third layer: it depends on kernel crates and, for v1, directly onents-forgeandents-kilntoo, hardcoding a custom page per package where one is genuinely needed (a toolchain’s recipe provenance, a comment’s anchor projection) — legitimate for a leaf consumer exactly asgit-ents’s own package-specific subcommands are, not a layering violation. The one rule that binds inside `ents-webitself is narrower than the crate-level layering rule: its generic rendering path — the schema-driven list/view mechanism driven byfacet::Shapereflection, the UI analog of the gate executor — must never match on which concrete entity type it was handed. A generic-path need to know a concrete type is a missing generic capability to add to that mechanism, or grounds for a legitimate custom page; it is never grounds for amatchinside the generic renderer.git-entsdepends onents-web(for itsservesubcommand), the one edge between the two leaves;ents-webnever depends back ongit-ents.
The one-way rule is absolute, not just directional-on-average: a kernel crate must not depend on a package crate in any form — not a normal dependency, not a dev-dependency, not a build-dependency, not behind a feature flag.
ents-testutil in particular must not know a package’s types; a package needing test fixtures beyond the kernel’s generic ones adds them inside its own crate, not by teaching ents-testutil its vocabulary.
Ref-namespace segments (refs/meta/issues/*, refs/meta/comments/*, refs/meta/toolchains/*, and so on) stay kernel-reserved regardless of which layer owns the entity stored there: every namespace is minted by one function in ents_model::namespace, and a package calls that function rather than inventing its own ref layout.
This is what keeps meta-ref.granularity and meta-ref.namespace a single, auditable surface even as entity types move between crates — a package can gain or lose ownership of a type without the ref layout itself ever needing to change.
''
Derived, not fundamental
Instances and consequences of the six, listed for orientation:
-
CI checks — the first shipped effect: trigger on branch pushes, run a command, publish results.
-
Pipelines — query composition, not a subsystem: staged CI is
rev(refs/heads/main) ∩ results(unit, pass), fan-in is intersection, conditional edges are difference. The pipeline state is the results namespace. -
The inbox workflow — the email-patch model rebuilt on the primitives: inbox ref as mailing list, adoption merge as the maintainer applying a signed patch.
-
User-funded CI — hosted CI is not load-bearing: a contributor with no canonical push access gets full CI on the real toolchains, self-executed, results shareable and verifiable. The server’s monopoly reduces to custody of the canonical refs.
-
Toolchains — typed trees under
refs/meta/toolchains/*; a resource effects declare, not a trigger. The repo carries its own execution environment with provenance, as ~1KB hash-pinned manifests; only the sandbox touches the bytes. They keep a subcommand only because import/activation logic is nontrivial; if that shrinks, the subcommand dies. -
Members, accounts, issues, comments — typed trees (1) on ledger refs, written as signed ledger commits (3), admitted by the gate (5). Issues are the plainest instance of all: assignees, labels, and states are struct fields, so multiple assignees or custom states are schema, not platform features.
-
Fanout indexes — discovery without ref enumeration:
refs/meta/index/*maps object oids to the entities anchored to them, rebuilt by an effect (6) and written via the worker’s signed ledger commit (3). Clients read the index; a stale or absent index degrades to scanning ref tips, never to wrong answers. -
Redaction — the deliberate exception to retention: an object-level yank, scoped to content reachable only from
refs/meta/*. The bytes are withheld from the store and from generated packs; the oid stays in history as evidence; signatures and the tip invariant are untouched, because verification never reads the withheld bytes. A yank is recorded as a signed entity underrefs/meta/redactions/*and enforced at ingest, because content addressing would otherwise let anyone holding the bytes refill the hole exactly. Readers surface a redaction marker, never an error. Best-effort by nature: nothing recalls bytes from clones that already hold them. Code-history redaction remains git’s ordinary rewrite-and-force-push problem, deliberately out of scope. -
Rendering registry — documents render by MIME type through one lookup table (HTML web, plain-text CLI; unknown types pass through). Implementation choice.
-
Embeddable server —
git-ents-serveris a library first; the serve command, standalone binary, and hooks-as-subcommands are thin wrappers. This is not merely packaging: it is what keeps all authorization and effect-matching logic in the library rather than smeared across subprocess boundaries.
''
Command surface (consequence of the abstractions)
Primitives are local plumbing; git ents adds exactly one capability: remote synchronization (fetch the relevant refs/meta/* first).
git store show refs/meta/member/joey # generic escape hatch: pretty-prints
git store show refs/meta/effects/ci # any typed meta-ref, known or not
git anchor resolve
git anchor project HEAD
git comment add --file src/lib.rs --lines 40-52 -m "why saturating_add?"
git comment show
git effect list
git effect add ci --on 'refs/heads/*' --toolchain rust-1.88 -- cargo test
git effect add integ --on 'rev(refs/heads/main) & results(ci, pass)' \
--toolchain rust-1.88 -- cargo test --features integ
git effect show ci [<commit>] # definition + results for a commit
git effect run ci [--at <commit>] [--executor docker|sprite]
# local execution: identical toolchain
# materialization and sandbox path,
# queue skipped — nothing else differs
git effect log ci # results ref history
git toolchain import rustup:1.88-aarch64-apple-darwin
git toolchain view rust-1.88
git toolchain log rust-1.88
Two correctness anchors: git effect run shares the identical materialization and sandbox path with the hosted worker, and every mutation frontend shares the identical receive with the hosted server — or the abstractions are decoration.
Porcelain:
git ents setup [--hosted] git ents bootstrap <username> git ents members list|add|remove|revoke|unrevoke|check git ents account show|create git ents effect list|show|add|run|log git ents toolchain list|import|view|log git ents comment list|add|reply|resolve|reopen|show git ents issue list|show|new|edit git ents review new|withdraw|list|show git ents inbox list|adopt git ents redact list|add <oid> git ents login git ents serve [--hosted] git ents lsp git ents hook pre-receive|post-receive|reconcile # plumbing for git's own hooks
The entity listings (comment list, issue list, review list, effect list, effect log) take --porcelain, emitting one shared machine-readable record grammar — the format git ents comment list --porcelain established — rather than a per-family format.
Revocation is a state on the member entity, not deletion — a revoked key must be explicitly rejected, whereas deletion would merely make old signatures unverifiable.
''
Deployment (consequence of the abstractions)
Local (git ents serve) |
Hosted (git-ents-server) |
|
|---|---|---|
Repositories |
Real working repositories |
Bare repositories |
Discovery |
Current repo or directory scan |
Created on first push |
Ref store |
Loose refs via |
Postgres rows, atomic CAS |
Object store |
The odb |
Tigris |
Gate |
Advisory — writes annotated, never blocked |
Mandatory — failure aborts the CAS |
Effects |
Pull, via |
Push-triggered, durable queue, Fly.io Sprite |
Purpose |
Personal forge, development, demos |
Production forge |
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 the ledger (3) and the gate (5).
Bootstrap remains the known gap: an empty member list admits every push so the first member can enroll.
Hosted, this leaves "first push owns the repo" open per repository; closing it — repository creation requires an existing account — needs a server-level key→account registry, because bare repos are created on the first info/refs request, before any push certificate exists.
Direction chosen, not yet enforced.