Artifact e3c9c890c5157bf129c25a5eeb6d55e78e184a9f8e1e55c5243091fa45754df3:
- File docs/rfcs/0024-horizons.md — part of check-in [16540afe82] at 2026-10-23 21:37:02 on branch history-api — Annotate in the API, and /history answers with the history interface (user: dmestas, size: 18549) [annotate] [blame] [check-ins using] [more...]
- Feature Name:
horizons(serving clones that hold history only from a check-in forward: the content horizon through the sync front unchanged, the structural horizon from quarry's own indexes; the history below the horizon answered by the API; which consumer gets which tool) - Start Date: 2026-10-17
- Status: Draft — the corpus measurement (2026-10-17) is in: structure is the bulk of every long-lived store, so the structural horizon is the deliverable and the content horizon is its base; two decisions settled by the owner on 2026-10-18 (see Decisions); nothing here is built
- Depends on: RFC 0001 §2 (store access through the zit binary), §7 (sync
fronting), §9 (the ops layer generates the API), §11 (disposability);
RFC 0009 (archives, premerge, runners); RFC 0021 (agent accounts, run-scoped
tokens); zit's
draft-fossil-horizon-00(the wire, the seed, the held set) anddraft-fossil-sparse-cone-00(the cone the horizon is a member of)
Summary
A horizon clone holds a repository from a check-in C forward. zit's horizon
draft defines two cuts. The content horizon keeps every structural
artifact and elides the file blobs no held check-in references; it needs
nothing from quarry beyond fronting /xfer, because the zit subprocess
serves it. The structural horizon also omits the manifests below C and
stands a seed, a private control artifact asserting the tag state at C,
in their place; it needs an origin that mints seeds, labels the hashes that
cross the horizon, and answers merge-base. quarry is that origin, and this
RFC says how: the seed and the labels are derived from the index plane the
same way every page is, the merge-base answer comes from the same ancestry
index, and all three pass the §11 disposability test because they are pure
functions of store state.
The history a horizon clone does not hold is still on quarry. The RFC names the API that answers for it (annotate, path history, diff and file at a check-in, already owed under principle 5), adds one read endpoint quarry lacks (an artifact by hash with immutable caching), and settles which consumer gets which tool: build-and-test runners already fetch archives and keep doing so; persistent workers keep a warm clone; cold agent environments get the horizon clone, content first.
Motivation
RFC 0001 made quarry the always-on side of zit. The store is the truth and quarry derives everything else, so a clone that leaves history on quarry loses nothing a person cannot get back by asking quarry. That is what makes a shallow clone safe to offer here when it is not safe to offer against an arbitrary Fossil peer: there is a dense, indexed, always-reachable copy, and every page quarry renders is already an answer about history the client need not hold.
The consumer that forces the question is RFC 0021's agent: a short-lived environment on a dagnats worker that must clone, commit and push, with no warm cache and a network between it and the store. RFC 0009's runners do not have this problem, because premerge removed the clone from the test path: a runner fetches an archive of a tree and never holds a repository. Agents cannot use that trick. They write.
zit's horizon draft keeps every artifact's real name and the existing derivation. What it asks of an origin is small and index-shaped, and quarry already has the indexes.
Guide-level explanation
An agent's environment starts with:
$ zit clone --horizon https://quarry.example/r/proj
quarry's sync front sees the horizon pragma, computes the held set from its ancestry index, and the zit subprocess sends every structural artifact and the blobs of the held check-ins. The clone opens, derives branch state exactly, commits, pushes. The push is an ordinary push.
With --horizon=structural, quarry additionally sends the seed for C and
labels every parent hash that lies below C. The clone's timeline ends at a
horizon edge rather than a missing parent. When the agent runs zit annotate
on a path older than C, zit prints the horizon and the quarry URL that
answers for it; zit merge of a branch whose base is below C asks quarry
for the base and deepens to it before merging.
On the repository's page nothing changes. A horizon clone's check-ins are the same check-ins, under the same hashes.
Reference-level explanation
1. The content horizon costs quarry nothing new
RFC 0001 §7's front passes pull traffic to the per-repo zit serve child
untouched, and the child computes the held set and the keep set from the
store. quarry's only obligations are two it already meets: front the
request, and keep the child's store dense. The capability pragma
horizon-served content is the child's, not quarry's.
One front-side rule is new. The §7 gate decodes the request container to
inspect login and push cards. It MUST pass pragma horizon* cards through
untouched, including to a child whose version predates them, which ignores
them by the sync protocol's rule.
2. The structural horizon: three answers from the index plane
The structural horizon needs an origin to mint seeds, label boundary hashes, and answer merge-base. Each is a function of the store's state and nothing else, so each is derived data under RFC 0001 §1 and must pass §11: delete the data directory, re-ingest, get the same answers.
Where they are computed, as built. zit's own serve mints the seed,
labels the boundary and answers merge-base from the repository view it
opens per round, so a quarry that fronts the child serves the structural
horizon with no quarry code at all: the child's horizon-served structural,
its labels and its seed pass through the §7 front untouched like every other
pull card. That is the implementation on zit's horizon-rfc branch and the
one this RFC now assumes. The index-plane minting below remains the design
for the day the child's per-round open is the hot path on a large store;
nothing in the wire format changes when it moves.
Where they would be computed in quarry. From the per-repo SQLite index that ingest (§4) already maintains for branch state, leaves and ancestry, not in the zit child. Two reasons. The child is one-shot and stateless, and an ancestry walk per request against a large store is the work §4's index exists to avoid. And the seed asserts the tag state quarry displays; minting it from the same derivation keeps the clone's view and the page in agreement by construction.
How they reach the wire. The front already decodes the request. For a
request carrying horizon-structural it computes the three answers,
encodes them as the reply pragmas the zit draft specifies (horizon-below,
horizon-seed with a private file card, horizon-mergebase), and splices
them into the child's reply before re-encoding. The child continues to
decide which manifests and blobs to send; quarry adds only what the child
cannot know. This is the one place the front writes into a reply body, and
it is confined to pragma cards and one private file card.
The seed. A control artifact whose T cards apply to C every tag in
force at C: the branch tag with its value, every other propagating tag,
closed if set, and C's own non-propagating tags, each with the value the
index holds. D card at minting time, no earlier than the latest tag event
summarized and strictly later than any seed it replaces. U card is the
repository's forge agent login (RFC 0021, forge agents), so a seed is never
attributed to a person. Clear-signed with the forge agent's key when the
repository has one configured.
A seed is minted on request, compared by hash to the one the client offers, sent only when it differs, and never stored. There is no seed table. A repository with a thousand horizon clones at a thousand different C's costs quarry a thousand index reads per sync round and nothing between rounds.
Boundary labels. For each manifest the child's reply carries, every P
card naming a check-in not in the held set is a boundary hash. The front
emits horizon-below for each, in the reply that carries the manifest. The
held set is the zit draft's leaves rule by default.
Merge base. horizon-mergebase A B is answered from the ancestry index
with the same bounded walk the timeline graph uses. No common ancestor
answers -.
Refusing a seed. A pushed artifact that is a control artifact applying
to a check-in exactly the tag set the index holds for it, with a U card
naming the forge agent, is a seed that leaked. The front refuses the push
with the sync protocol's error card and the text seed is private to the
clone that holds it.
3. The push-time cancel check
A commit from a structural horizon that creates a branch writes a cancel
card for the parent branch's symbolic tag, derived from the seed. On every
push the front already inspects manifests (RFC 0007's branch ownership). It
additionally checks each -sym-* cancel card on a branch-creating manifest
against the index's branch for the parent. On disagreement it refuses with
stale seed: parent is on <branch>, not <name>; re-sync to re-seed. This is
the one route by which a clone's derived state enters an artifact, and
checking it here keeps a wrong seed from ever reaching the store.
4. The history below the horizon: the API
The zit draft's horizon-served pragma carries a URL. quarry's is
/api/v1/r/<repo>/history, which answers with an index of the query ops
below (principle 5); the URL is informational in the draft, and the index
is what a person or tool that follows it finds:
| Op | Route | Status |
|---|---|---|
| index | GET /api/v1/r/{repo}/history (history.index) |
built: each question below with its route |
| annotate | GET /api/v1/r/{repo}/annotate/{checkin}/{path} (file.annotate) |
built over zit annotate --full, with each line's user from the index |
| path history | GET /api/v1/r/{repo}/history/{checkin}/{path} (file.history) |
built; this is the finfo the first draft listed as owed |
| diff | GET /api/v1/r/{repo}/checkin/{b}/diff?from={a} (checkin.diff) |
built |
| file at check-in | GET /api/v1/r/{repo}/file/{checkin}/{path} (file.get) |
built |
| merge base | GET /api/v1/r/{repo}/mergebase/{a}/{b} (checkin.mergebase) |
built |
| artifact by hash | GET /api/v1/r/{repo}/artifact/{hash} (artifact.get) |
built |
All are registered ops in the §9 registry, so they appear in the generated OpenAPI and pass the §11 snapshot. None is a new capability; the horizon is the reason to finish the list.
Artifact by hash is the one genuinely new read. It returns the artifact's
bytes, Content-Type: application/octet-stream, with Cache-Control:
public, max-age=31536000, immutable, because an artifact's content is its
name. It is what a horizon clone's targeted demand fetch and an edge cache
both want: one blob, no sync round. Authorization is the repository's read
capability as for every read op. A private artifact (the sync protocol's
private marker) is never served here.
5. Which consumer gets which tool
| Consumer | Tool | Why |
|---|---|---|
| Build-and-test runner (RFC 0009) | /archive/<checkin> and /archive/pair: |
Already the design: one tree, no clone, no credential. This RFC changes nothing for it. |
| Persistent worker | a dense clone kept warm per host, synced forward, each job on a copy-on-write copy of the .zit file |
Pays the clone once per host. Outside quarry's control; written here so the horizon is not sold to it. |
| Cold agent environment (RFC 0021) | horizon clone, content first | Must commit and push; has no warm copy; talks only to quarry. |
| A person at a keyboard | dense | RFC 0001 principle 7 names zit clone as the exit, and a default clone that is not a full copy weakens that promise. |
The agent-account token minted for an environment (RFC 0021, run-scoped tokens) carries no horizon. The clone command does. Making the horizon a property of the token was considered and rejected: a clone shape is the client's choice, and encoding it in a credential would make the credential mean two things.
6. Disposability (§11), applied
Three new derived answers, one test each in the §11 corpus run: for a fixture store and a chosen C, snapshot the seed's T cards, the boundary set, and a merge-base answer; delete the data directory; re-ingest; the three must be byte-identical except the seed's D card, which is minting time. The D card is excluded from the comparison and asserted monotonic instead.
7. Build order
The measurement the zit draft's Appendix A asked for is done. On every
corpus measured, structural artifacts are between a third and nine tenths
of the stored bytes, and a content horizon alone leaves more than half of
a dense clone in place; tip-tree blobs are tens of megabytes everywhere.
The structural horizon is therefore the deliverable, the content horizon
is the base it stands on, and the default held-set rule is descendants
(the leaves rule holds every manifest from each open leaf back to its
fork, which on repositories with old open branches costs several times
the tip's blobs). Rungs 1 to 3 are unblocked now; rungs 4 to 6 wait only
on zit's structural horizon.
| Rung | What | Waits on |
|---|---|---|
| 1 | Front passes horizon* pragmas through untouched |
done; TestHorizonPragmasReachTheChildUntouched |
| 2 | The history interface of §4 | done: every row of §4's table is built, /history answers with the index |
| 3 | horizon-served URL pointed at /api/v1/r/<repo>/history |
zit 0.7.1's zit serve --horizon-url, passed by the front at registration |
| 4 | Seed minting, boundary labels, merge-base pragma splice in the front | not needed while the zit child mints (see §2); an optimization for later |
| 5 | Push-time cancel check; seed refusal | the cancel check is in the zit child on every push; seed refusal in quarry is still open |
| 6 | §11 rows for the three answers | rung 4 |
Drawbacks
- The front writes into a reply body for the first time. It is confined to pragma cards and one private file card, and only on requests that asked for it, but RFC 0001 §7's "pull traffic passes through untouched" gains an exception and the gate gains a code path that must parse the child's reply, not just the request.
- A wrong seed is quarry's wrong derivation delivered to a clone. The blast radius is the clone's local view plus the one cancel card, which rung 5 catches. It is still the first time quarry's derived state is asked to stand in for artifacts rather than describe them.
- The structural horizon is quarry-only. A clone made this way cannot be re-pointed at a stock Fossil server and keep its shape; it heals to a content horizon or to dense. The zit draft states this; it is restated here because the exit guarantee of principle 7 is quarry's promise to keep.
Rationale and alternatives
- Index plane, not the zit child. The child could compute all three answers from the store, and against a stock zit server that is what the draft permits. quarry has the index and the §11 test; using them keeps the seed in agreement with the page and keeps the per-request cost an index read.
- Splice at the front, not a second endpoint. The seed and labels could arrive over the API in a separate request. They ride the sync reply because the draft specifies them as pragmas, so a zit client needs one code path for every origin, and because the labels must arrive with the manifests they qualify.
- No seed storage. A seed table would be a §1 derived-plane table with nothing to derive from except the index it duplicates. Minting on request is cheaper to keep correct than caching and invalidating.
- Archives for runners, unchanged. The horizon could have been offered to runners too. They are better served by the tree they already fetch, and offering a clone to a job that holds a deploy credential (RFC 0009, "a runner holding a secret runs one branch") would widen what the credential can reach.
Prior art
- Git's partial clone (
--filter=blob:none) is the content horizon's shape: all commits, no old blobs, on-demand fetch. Git's shallow clone (--depth) is the structural horizon's shape without a seed: real commit hashes, parents recorded as absent in a side file, branch state carried by refs rather than derived. Fossil has neither; the sparse-cone draft gave zit the first and this gives it the second. - Scion's thin client holds nothing it is not using and trusts a stateless server for every derived answer. The structural horizon lands between that and a dense clone: it trusts the origin for one control artifact and derives everything else from real artifacts it verifies by name.
Decisions
Settled by the owner on 2026-10-18.
horizon-servednames the history namespace root,/api/v1/r/<repo>/history, and the client composes the route it needs (annotate, path history, diff, file, merge base, artifact). The draft says "an interface"; one root covers every lookup now and later without changing what the pragma means.- A repository may opt out of serving structural horizons: a
repository setting
horizon.structural: false, default on, the way it may opt out of verdict tags (RFC 0001 §6). A steward who does not want quarry's derivation standing in for history can say so; the content horizon, which holds real artifacts throughout, is still served.
Neither horizon needs sub-manifests: zit's horizon draft holds manifests whole on a flat or hybrid history, and only the sparse path cone depends on the S-card tree. Every store quarry serves, flat ones and Git imports included, can be cloned at a horizon.
Future possibilities
- A horizon suggestion in the clone instructions quarry renders on a repository page: the common ancestor of the open leaves, recomputed per render, so a person copying the command gets a C that keeps every live branch's divergence held.
- Deepen as a page action: a horizon clone's
zit annotateprints a URL; the page at that URL could offer the deepen command with the computed C.