Skip to content
quarry Artifact [03180569f9]
Login

Artifact [03180569f9]

Artifact 03180569f9a86cc27c0e2b71518cca51fdb1a74c34d7f37867a3fa3f9c8563db:


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.

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)

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

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

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