Skip to content
quarry Documentation
Login

Documentation

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:

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.

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)

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

What this is not

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

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

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

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.