- Feature Name: `protected-branches` (branch ownership, enforced at the one
  place identity is a fact)
- Start Date: 2026-08-26
- Status: Draft for review
- 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
[summary]: #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
[fact]: #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
[rule]: #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-B` tag;
- a control artifact that adds or cancels `sym-B` on 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
[storage]: #where-the-policy-lives

```sql
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`:

```go
// 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
[enforcement]: #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:

1. The gate **always** sets `X-Zit-Protect` on every request it forwards to
   a `--fronted` child, 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.
2. The gate **strips** any inbound `X-Zit-Protect` before forwarding. The
   child cannot tell quarry's header from a client's; on loopback behind
   `--fronted` a forwarded client header is a bypass.
3. 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)
[compat]: #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
[strata]: #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
[caps-events]: #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
[disposability]: #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]: #what-this-is-not

- **Not a required-reviewer rule.** `docs/landings.md` deliberately 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 `i` means 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 `3` may 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-settings` owner 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
[testing]: #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
[build-order]: #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
[open]: #open-questions

- **Concurrent accepts** (RFC 0006's open question) are unchanged by this and
  still unexamined.
- **Membership vs. state.** `closed` on 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 (every `a` holder) rather than a list is a settings-page
  question, not a model one.

## The cost worth stating
[cost]: #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)
[appendix-a]: #appendix-a-the-zit-issue-as-filed-zit151-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 serve` authenticates every push (sync §6.3) and
> knows things the store cannot: who is pushing, and which branches that
> account may write. `zit serve` knows 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`) carries `binary_glob` / `crlf_glob`,
> resolved per round from repository settings (`src/cmd/sync.zig:540`) and
> evaluated by `pushPolicyFault` before ingest, refusing the whole round with
> a `content_policy` error card. Smoke S11 proves stock fossil renders it.
>
> **Ask.** Two pieces:
>
> 1. 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 a `sym-*` tag it carries — or (b) a control artifact
>    adding or cancelling `sym-*` 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.
> 2. A per-request channel for that list: a request header, honoured only
>    when `zit serve` was 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 serve` should 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.
