- Feature Name:
protected-branches(branch ownership, enforced at the one place identity is a fact) - Start Date: 2026-08-26
- Status: Implemented (zit#151 shipped)
- Depends on: RFC 0001 §7 (sync fronting), §8 (OLTP and auth), §11
(disposability); RFC 0004 (capability model);
docs/landings.md; RFC 0006 (strata) - Upstream: zit#151, shipped. Appendix A is the issue as filed; §Enforcement records what shipped and where it differs from the ask. Everything else is forge-side.
Summary
A repository may declare that a branch is protected and name its
owners. A check-in joins a protected branch only by an owner's hand —
whether that hand is an accept on the landings page, a zit landing accept
on a clone followed by a push, or a stock fossil client pushing a merge.
Because accept writes a merge check-in onto the target, "only an owner may
accept a landing on trunk" is a consequence of the rule, not a second rule.
The policy is quarry's, lives in quarry's OLTP beside the capability tables, and leaves no trace in the repository. A clone of a protected repository is byte-identical to a clone of the same repository unprotected. Nothing on the fossil wire, in the artifact format, or in the capability vocabulary changes.
The fact this hangs on
A landing's accept is recorded as three ordinary artifacts, and the author on
each is a U card — a string any clone can write. Fossil has no signature
card, and docs/landings.md forbids inventing one. So who accepted is not
knowable from the record. It is knowable at exactly one moment: when the
artifacts are written, by whoever authenticated the writer.
RFC 0001 §7 puts quarry in front of every sync connection, and its gate
already "decodes the request's §4.1 zlib container and inspects only the
login/push cards." The login card is verified against the §6.3 shared secret.
That is the fact. The U card is a claim. Protection keys off the first and
must never consult the second.
The rule
There are two candidate rules and only one of them is real:
| rule | governs | holds against a CLI or stock fossil push? |
|---|---|---|
| a landing on B may be accepted only by an owner | the ceremony | no — i still lets anyone merge to B and skip the ceremony |
| a check-in may join B only when written by an owner | the branch | yes |
The second is the primitive; the first falls out of it. A proposal that implements only the first should be named for what it is — an acceptance policy, not branch protection — and this document does not propose it.
Normative. For a protected branch B with owner set O, an artifact that changes B's membership is admitted only when the authenticated writer is in O. Membership changes are:
- a new check-in whose resolved branch is B — by inheriting its primary
parent's branch, or by carrying a
sym-Btag; - a control artifact that adds or cancels
sym-Bon an existing check-in.
Branching off B (a check-in whose parent is on B but which starts another
branch) does not change B's membership and is not gated. Neither is any
other tag on a check-in that is on B — closed, hidden, CI verdicts,
landing outcomes. Those are separate policies, if they are policies at all.
"Resolved branch" is crosslink's answer, which is why the store evaluates it and quarry's index does not (§Enforcement).
Where the policy lives
CREATE TABLE IF NOT EXISTS branch_protection (
repo TEXT NOT NULL,
branch TEXT NOT NULL, -- a name or a glob: trunk, release-*
owners TEXT NOT NULL, -- account logins, space-separated
PRIMARY KEY (repo, branch)
);
Forge OLTP, the same class of state as RFC 0004's repo_caps: authoritative,
non-DAG, litestream-replicated, and untouched by the §11 wipe. Edited under
a (Admin), which is what gates repo.update in RFC 0004's op table. Empty
by default: an imported fossil repository behaves exactly as it did until an
administrator adds a row.
One decision function, memoised per request like EffectiveCaps:
// MayJoin: no protection row matches branch, or user ∈ owners.
func (db *DB) MayJoin(repo, user, branch string) bool
This is quarry policy in quarry's clothes. It is not a capability letter.
RFC 0004 and docs/landings.md both refuse to invent one, and this RFC
keeps that refusal: the effect on a quarry-hosted repository is i minus
the protected branches an account does not own, stated as forge policy in the
settings page, never disguised as fossil's.
Enforcement
MayJoin has two call sites, because a merge check-in reaches the store by
two paths.
The xfer gate: quarry decides, zit enforces
A push arrives. The gate authenticates the pusher (shipped, §7), then computes
the set of protected branches that pusher does not own, and hands the
per-repo zit serve child a per-request refusal: refuse any artifact
that changes the membership of {trunk, release-*}. The child evaluates it
over the staged push through the #88 push-policy seam it already has and,
on a violation, refuses the whole round with an error card naming the
branch — the same batch semantics #88 chose ("accepting the conforming half
would leave the client's DAG naming artifacts this server declined") and the
same card smoke S11 proves a stock fossil client renders.
The division is deliberate. zit never learns who pushed; it receives a rule
and evaluates it against the store, which is the only thing that can answer
"which branch does this check-in resolve to." quarry never parses a push
payload; it turns an identity into a rule, which is the only thing OLTP can
answer. A bare zit serve with nothing in front of it receives no rule and
protects nothing — correct, because it has no identity to protect with.
The channel is a request header the child honours only when started with a
flag declaring a trusted front (--fronted). Without the flag the header is
ignored, so a zit serve reachable directly is unaffected. This is between
quarry and its own child on loopback; it is not on the fossil wire.
What zit#151 shipped. The header is X-Zit-Protect, a list of branch
globs, capped at 4096 bytes (over-long answers 431). It is lifted only by a
--fronted responder. A parent the server does not yet hold resolves to
null and is not a violation — the child is judged when the parent arrives
— but a resolver that cannot answer at all is a read fault and the rule
fails closed on it. Membership resolution matches the ask: a check-in's
own propagating branch/sym-* tag, else its primary parent's branch,
in-round or in-store; and a tag artifact adding, propagating, or cancelling
sym-B on a held check-in.
Where it differs from the ask, and what that puts on quarry. An absent
or empty header is no rule: protect_globs of length zero returns before
any decision. That is correct for a bare zit serve, which has no front and
nothing to protect with. Behind a front it means a gate bug — a code path
that forgets to compute the rule — silently disables protection for every
push through it, and nothing observes the failure. zit shipped this way and
the RFC does not ask it to change; the fail-closed guarantee is therefore
quarry's obligation, stated as three requirements:
- The gate always sets
X-Zit-Protecton every request it forwards to a--frontedchild, computed per request from the authenticated identity — an empty value when that identity owns everything, never an absent header. "Empty because computed" and "absent because forgotten" must be different code paths, and only the first may exist. - The gate strips any inbound
X-Zit-Protectbefore forwarding. The child cannot tell quarry's header from a client's; on loopback behind--fronteda forwarded client header is a bypass. - The harness asserts both: no request reaches the child without the gate's header, and a client-supplied header never survives the gate.
If (1) proves fragile in practice, the fix is a follow-up to zit making a
--fronted child refuse a request with no header at all — not a quarry-side
workaround, and not asked here because the obligation is cheap to meet and
the harness proves it.
The accept op: the forge is a writer too
landing.accept runs on the lease holder and writes to the store directly,
so it does not pass the gate. The op checks MayJoin(session user, target)
before acting, and the landings page disables Accept for a non-owner with
the reason — trunk is protected; owners: alice, bob — in the posture
docs/landings.md already uses for a conflicted landing: a control that
would fail is not offered.
One departure to state plainly. RFC 0006 says "the guard is upstream; quarry disabling the button is the courtesy." This is the first landing rule where that cannot hold. zit has no identity, so for protection the guard is necessarily the forge's. That is the correct placement, not a compromise.
The record follows the branch
A transition reply is a forum post (3). Anyone who may speak may post
zit-landing-state: accepted, with or without a merge behind it — true today,
and tolerated because inside a cathedral the i holders are trusted. Under
protection the discrepancy becomes meaningful: a record could claim
accepted on a branch nobody landed.
So quarry's fold takes the merge as the fact and the reply as the claim, for every landing, protected target or not: a landing renders accepted when the DAG holds the check-in that merged it, and a reply asserting acceptance without that merge renders as a claim, not a state. This is derived from the DAG, in the spirit of "a verdict that was already an artifact."
Unconditional on purpose. An earlier draft applied this reading only to protected targets and kept the reply-based reading elsewhere — which made the fold's rendering of a landing depend on a policy row, so adding or deleting protection re-judged every historical landing on that branch. That is exactly what §Disposability forbids. A fact about the DAG is the same fact under any policy; only the policy-conditional rule was the problem.
The merge is identified structurally, not by ancestry of the tip. A
merge check-in lists its primary parent first and the branches it merged
after, so the check-in that landed a proposal is the one on the target whose
non-primary parent is on the proposed branch, the earliest such after the
landing was opened. quarry already resolves this (MergesForLandings,
18 of 18 accepted landings on this repository). The test an earlier draft
proposed — "the branch tip is an ancestor of the target tip" — is wrong for
a branch that was merged and then continued: the tip is no longer an
ancestor, and a settled landing would un-settle. Bounding on the landing's
open date rather than its accept date matters too: the merge is written
before the artifact recording the verdict, by minutes in practice.
Consequences worth stating. An owner who merges from a clone and pushes but never posts the transition reply has, in fact, accepted, and the record says so. A reply-only "accept" on an unprotected branch — possible today, only by hand — surfaces as a claim everywhere, which is what it was.
Rejected alternative: having the gate also refuse forged transition replies. That is a second rule, needs quarry to parse forum posts at push time, and still trusts a reply over the DAG. The fold change trusts the DAG.
Fossil compatibility (normative)
This feature must not affect fossil compatibility. Per surface:
| surface | change |
|---|---|
| sync wire protocol | none. No new pragma, card, or media type. A refusal is the existing error card with descriptive text — the path #88 uses, which a stock fossil client already renders. |
| artifact format | none. No new card, no new artifact type, no byte written to the repository. Protection leaves no trace in the store. |
| user table and capabilities | none. No new letter. i keeps its fossil meaning everywhere; on quarry, forge policy stored outside the repository refines it per branch. |
| stock fossil client | clone and pull unchanged. Push to an unprotected branch unchanged. Push of a membership change to a protected branch by a non-owner: refused with a readable message — the same experience as pushing without i. |
| the same repository hosted elsewhere | a fossil server, chiselapp, or bare zit serve holding this repository has no protection. The policy does not travel with the repository, by design. |
| import, mirror, export | import-fossil, config pull, mirrors, fossil git export: unchanged. A protected repository is byte-identical to an unprotected one. |
Rule. If the implementation reaches for a new card, a new pragma, a new capability letter, or a byte in the repository, the design is wrong.
Strata
Accepting segment N retargets N+1 onto the branch N landed on (zit#144). If that branch is protected, N+1 now needs an owner. Nothing here has to know that: the rule is evaluated against the current target at accept time, and the merge check-in N+1 would write joins the protected branch either way. quarry re-evaluates the disabled-control state after a retarget, which the fold already does.
Capabilities and events
Capabilities: unchanged. landing.accept still requires i; editing
branch_protection requires a. No new letter (RFC 0004 §4).
Events: no change to the landing vocabulary. landing.accepted is raised by
the same fold transition as today. A protection-settings change rides
whatever repository-settings event exists; branch.protected would pass RFC
0005's noun test if one is wanted, and is not required.
Disposability
Rows live in OLTP and survive the §11 wipe, exactly as caps do. Decisions already taken are in the store as artifacts. Policy is enforced at write time only: after a wipe and re-ingest the fold must not re-judge historical accepts against current rules, or a rule change would rewrite history's rendering.
This now holds by construction rather than by discipline: the fold reads
nothing from branch_protection (§The record follows the branch), so there
is no rule for it to re-judge against. The disposability harness gains two
assertions — the rendered landings of a protected repository are identical
before and after the wipe, and identical before and after the protection
row is added or deleted.
What this is not
- Not a required-reviewer rule.
docs/landings.mddeliberately allows accepting your own landing; an owner still may. A second-reviewer policy is a different policy and is not bundled here. - Not a capability. See §Where the policy lives.
- Not portable. It protects this repository on this forge. That is the honest scope of any rule that depends on who is holding the pen.
- Not a change to what
imeans in fossil. See §Fossil compatibility. - Not a gate on opening a landing. Opening one is a forum post and
changes no branch's membership, so anyone with
3may open a landing on a protected branch — and under quarry's one-open-landing-per-branch rule (newest root is current), may thereby displace an owner's open landing from the queue. Accept is still gated, so this is a nuisance and not a hole; the displaced record is kept and says where the conversation moved. A rule against it would be an opening policy, and is not bundled here. - Not owners in a tracked file. A
.zit-settingsowner list on trunk is seductive — trunk protection would guard its own owner list — but it puts one forge's account namespace into the DAG and ships a rule to every clone with no enforcer. Rejected.
Testing
The matrix is the spec, table-driven like RFC 0004's:
| writer | path | expected |
|---|---|---|
| owner | forge accept | accepted, merged |
| owner | CLI accept + push | admitted |
non-owner with i |
forge accept | control disabled; op refused |
non-owner with i |
CLI accept + push | whole round refused, record stays open |
non-owner with i |
stock fossil push of a merge to trunk |
refused, readable message |
non-owner with i |
control artifact retagging a check-in onto trunk | refused |
non-owner with i |
commit branching off trunk | admitted |
non-owner with i |
push to an unprotected branch | admitted |
anyone with 3 |
forged accepted reply, no merge |
renders as a claim, not accepted |
| owner | CLI merge + push, no transition reply posted | renders accepted — the merge is the fact |
| owner | merges, then continues the branch past the merge | stays accepted; the tip is no longer an ancestor and must not matter |
| stacked N+1 after N lands on trunk | non-owner forge accept | control disabled |
| any client | request carrying its own X-Zit-Protect |
header stripped at the gate; child sees only the gate's |
| any client | any push through the gate to a --fronted child |
header present, always — an empty value when nothing is protected |
Compatibility: a stock fossil client sees the refusal text; a clone of the
protected repository is byte-identical to a clone taken before protection was
enabled. Disposability: per §Disposability. The --fronted flag: a child
started without it ignores the header.
Build order
| phase | work | where |
|---|---|---|
| 0 | membership-change refusal in the push policy; the --fronted per-request channel |
zit#151 — shipped |
| 1 | branch_protection table, MayJoin |
quarry OLTP |
| 2 | gate computes the per-request rule from identity; always sets the header; strips the inbound one | quarry xfer |
| 3 | landing.accept checks MayJoin; disabled control with reason; fold reads accepted from the merge, unconditionally |
quarry ops, web |
| 4 | settings surface under a |
quarry web |
| 5 | matrix harness; compat and disposability assertions | quarry tests |
Phases 1, 3 and 4 are useful before 0 lands — the forge path is protected on its own — but must be labelled acceptance policy in the settings page until phase 2 closes the push path, so the page never claims more than it holds.
Open questions
- Concurrent accepts (RFC 0006's open question) are unchanged by this and still unexamined.
- Membership vs. state.
closedon a protected branch's leaf is left ungated here. It is not a membership change, but it is a thing an owner might reasonably want to own. - Glob semantics.
release-*is the obvious need; whether owners can be a category (everyaholder) rather than a list is a settings-page question, not a model one.
The cost worth stating
Two things become true on a quarry-hosted repository with a protection row:
i means less than it means in fossil, and a stock fossil client can be
refused for a push its capability string says it may make. Both are stated on
the settings page, both are opt-in per repository, and both vanish the moment
the row is deleted — because nothing was ever written to the repository to
remember them by.
Appendix A: the zit issue, as filed (zit#151, shipped)
Kept as filed. What shipped differs in one respect — an absent header is no rule rather than a refusal — recorded in §Enforcement with the obligation it places on the gate.
zit serve: refuse membership changes to named branches, per request, when a trusted front says so (#88 push policy)A forge in front of
zit serveauthenticates every push (sync §6.3) and knows things the store cannot: who is pushing, and which branches that account may write.zit serveknows the one thing the forge cannot: which branch a pushed check-in resolves to. Branch protection needs both, and the clean split is the front decides, the store enforces.Today.
PushPolicy(src/sync.zig) carriesbinary_glob/crlf_glob, resolved per round from repository settings (src/cmd/sync.zig:540) and evaluated bypushPolicyFaultbefore ingest, refusing the whole round with acontent_policyerror card. Smoke S11 proves stock fossil renders it.Ask. Two pieces:
- A third rule in
PushPolicy: a list of branch globs whose membership may not change in this round. A pushed artifact violates it when it is (a) a check-in whose resolved branch matches — by primary-parent inheritance or asym-*tag it carries — or (b) a control artifact adding or cancellingsym-*for a matching branch on an existing check-in. Branching off a matching branch is not a violation. Resolution needs the parent's branch, which may be in the store or earlier in the same round. Refuse the whole round, same as #88, with the branch named.- A per-request channel for that list: a request header, honoured only when
zit servewas started with--fronted. Without the flag the header is ignored. The flag declares "a trusted proxy on loopback is my only client"; it is not a wire-protocol feature and must not be.Not asked. Identity.
zit serveshould never learn who pushed; the rule is the whole message. No new setting in the repository; this rule is never persisted, because it is different for every pusher.Acceptance. Unit: the three violation shapes above and the two non-violations, over parents in-store and in-round. Smoke: a stock fossil push of a merge to a named branch refused with the branch in the message; the same push admitted with no header; the header ignored without
--fronted. e2e: a round mixing one violation with conforming artifacts stores nothing.Consumer: quarry RFC 0007.