- 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: one zit change, drafted as an issue in Appendix A and not yet
  filed. 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.

### 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 for a landing whose target is protected, quarry's fold takes the **merge**
as the fact and the reply as the claim: the landing renders *accepted* when
the branch tip is an ancestor of the target tip, 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." Unprotected targets keep today's reading.

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. The disposability harness gains one assertion — the rendered
landings of a protected repository are identical before and after the wipe.

## 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 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 |
| stacked N+1 after N lands on trunk | non-owner forge accept | control disabled |

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 (Appendix A) |
| 1 | `branch_protection` table, `MayJoin` | quarry OLTP |
| 2 | gate computes the per-request rule from identity | quarry xfer |
| 3 | `landing.accept` checks `MayJoin`; disabled control with reason; fold follows the branch on protected targets | 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 drafted
[appendix-a]: #appendix-a-the-zit-issue-as-drafted

> **`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.
