Skip to content
zit Timeline
Login

Timeline

50 descendants and 50 ancestors of 58763bb760777fe7

2026-07-29
13:19
repocache, bisect, undo: checksum through the vendored crc32

The other three on-disk formats defined in terms of this checksum: the cache image self-checksum, the bisect session trailer, the undo journal trailer. Same swap, same values. check-in: 55d6e7c7bb user: dmestas tags: logstore-index

13:18
logstore: checksum through the vendored crc32

The four frame checksums plus the file header. The open-time scan CRCs every payload byte, so this is ~93% of a cold open: 416 MB goes 1.22s to 292ms. No format change — the values are identical by construction. check-in: dd71dd3006 user: dmestas tags: logstore-index

13:16
crc32: vendored slicing-by-8 CRC-32/ISO-HDLC

Bit-identical to std.hash.Crc32, which every on-disk checksum in the store is already defined in terms of. std's is byte-at-a-time over one table (~350 MB/s here); this folds eight bytes per step. Nothing uses it yet. check-in: aef8f539b5 user: dmestas tags: logstore-index

2026-07-28
22:58
rfcs: port applied errata; pin the §6.3 lowest-k tie-break and the one-name no-candidate refusal check-in: 307d7287fa user: dmestas tags: sub-manifest
18:29
submanifests: opt-in auto-retune dispatch and scalar /config propagation check-in: 4b882d36b8 user: dmestas tags: sub-manifest
16:43
submanifests: advise of drift at commit time, with an interactive retune offer check-in: 7b4e6df064 user: dmestas tags: sub-manifest
15:56
submanifests: add auto-retune setting and the §6.4 drift-check core check-in: 9c20a3746d user: dmestas tags: sub-manifest
11:49
submanifests: enable selects k by analysis instead of demanding one check-in: 081c3a0b90 user: dmestas tags: sub-manifest
11:42
submanifests: pin that the k objective counts only leaf-naming S cards check-in: b728cc6af5 user: dmestas tags: sub-manifest
07:02
Sub-manifest: settings, the emission gate, and the analysis pass

Emission is off by default and turning it on is an explicit act that must name the partition parameter: there is no fallback value, enabling without one is refused, and a pinned value outside the supported range emits flat manifests and says so rather than substituting a parameter of its own. Turning it off returns the next commit to flat. The analysis pass reports what the parameter would cost over a name set; it pins nothing.

A server whose setting is off now sends the avoid-submanifests pragma, which the receiving side already latched. It leads the pragma run in both the ordinary and the clone reply, so six tests that read a reply's first card were updated to account for it; each still asserts the same content it did before, behind the new card. Fossil 2.28 tolerates the pragma and the interoperability suite is unchanged.

The parameter is repository-local. The specification requires it to propagate to clones, since sharing by content address holds only while every writer partitions identically; that shortfall is recorded rather than implied away. check-in: 92a40960d1 user: dmestas tags: sub-manifest

04:54
Sub-manifest: sync and repository state for trees

An absent S reference now phantomizes like any other missing reference, its dependent is deferred and retried when the artifact arrives, and the sweep excludes sub-manifests so it still terminates. Phantoms survive a restart through a rebuild-on-open invariant resting on a durable seen-submanifest setting rather than on records the log never wrote. The transfer gate decides by artifact shape on all three send paths, so a leaf is refused wherever a tree manifest is, and the memo returns its own copy rather than a view into pages the store may free.

The gate remembers what it withheld from a peer, which is what lets an exchange with a peer that refuses sub-manifests terminate at all. That record is now cleared only when the peer's announced version actually changes: a peer that re-announces the same version in every reply was clearing it every round, so the same artifacts were offered and refused forever.

One requirement is not implemented: a server whose setting is off must send the avoid-submanifests pragma, and emitting it changes the card sequence every clone-reply test walks. That is a protocol-shape change worth its own review rather than a rider here; the pragma is parsed and latched on receipt already. check-in: 8446adcd98 user: dmestas tags: sub-manifest

03:31
Sub-manifest: complete the conformance corpus

Sub-manifests have no reference implementation to check against, so the corpus is the oracle and had to be built rather than assumed. Adds the eleven vectors Appendix A.5 requires that were missing, among them the three-level tree whose absence is why the step-5 cursor contradiction survived into an implementation: no published artifact carried an interior node, so nothing exercised that path.

Every authored vector is generated by the implementation and then rechecked along a path that shares no code with it, and each derived name is compared against the constant the test pins, so no hash in the corpus is hand-written. Two fuzz targets cover S-card classification and the tree walk.

One A.5 bullet is not satisfiable as written: the re-tune vector asks for two parameters over the A.1 name list that yield trees sharing no sub-manifest, but that list's single qualifying boundary falls in the same place for the first three values and is suppressed above them. No vector was invented for it; an erratum is drafted and a test pins the finding so it stays visible. check-in: 72bba34ada user: dmestas tags: sub-manifest

02:47
Sub-manifest draft: carry the step-5 cursor correction

Regenerated from the RFC suite after the erratum submanifests-00-s5.1-step5-cursor-advance was applied. Step 5 of the tree walk now advances the referencing frame's cursor before pushing the interior frame; as published it omitted that, so returning from an interior descent re-read the same S card and failed the strictly-greater-than-LAST check, leaving every tree with an interior node unwalkable. The walk already implements the corrected reading. Also picks up an MD5 citation and reflowing in unrelated paragraphs. check-in: f2973c41c3 user: dmestas tags: sub-manifest

01:49
Sub-manifest: record why the final-group merge cap is unpinned

The merge fires when the two groups together hold at most MAX, and no test separates that from a strict comparison. Reaching the distinguishing case needs a natural boundary at exactly MAX minus the tail, which hash-driven boundaries do not produce in generated name sets; a search over two parameter values and nine hundred starts found no instance. Pinning it would mean choosing filenames by their leading zero bits rather than generating them. The note records the reasoning at the site so the gap is visible to the next reader. check-in: 9897845809 user: dmestas tags: sub-manifest

01:19
Sub-manifest: accept in the S-card writer what the parser accepts

The S hash is validated case-insensitively and kept raw, so a parsed card carries whatever spelling it was given. The writer asserted the narrower lower-case spelling, so re-emitting a card the parser had just accepted tripped the assertion. It now asserts the parser's own predicate.

Reachable only once something emits, which nothing does yet; found by round-tripping an upper-case hash through create. check-in: 6e64c8976c user: dmestas tags: sub-manifest

00:45
Sub-manifest: canonical tree construction, inert behind the emission gate

Implements draft-fossil-submanifests-00 section 6: zbits over the decoded filename, the group scan with its MIN/MAX member bounds, and the final-group merge. One scan serves height 0 and every height above it, so the merge applies uniformly rather than through two code paths that can drift; appendix A.4 is the vector that fails when they do.

Construction emits filenames through a minimal encoder scoped to its own output only. Existing emission keeps the escaping it has always used, so no check-in already written changes its bytes. R cards are emitted for tree manifests alone.

Nothing emits yet: the construction is wired behind a disabled flag, leaves stored before the nodes that reference them. The gate that turns it on is a later change. Tests pin k = 3, the value the appendix vectors use, and cover byte-identity against A.2, A.3 and A.4, determinism across repeated builds, and a constructed tree walking back to the entries it partitioned. check-in: 9d7992613f user: dmestas tags: sub-manifest

2026-07-27
21:40
Sub-manifest: walk a tree manifest behind the enumeration facade

Implements draft-fossil-submanifests-00 sections 5.1 through 5.6: a stack walk over S runs with the section's depth bound, cycle check, strict ascent and first-filename identity, resolving sub-manifests through the existing baseline resolver. A tree that fails any check fails whole, so a partial enumeration is never presented as a file set.

The walk sits in a pre-pass at the facade, leaving enumerate, lookup and their call sites untouched as section 9.3 requires. R-card verification runs over the walked enumeration.

Step 5's cursor advance follows the erratum submanifests-00-s5.1-step5-cursor-advance: as published, step 5 omitted it, and no tree carrying an interior node could be walked. check-in: d1f955aaf3 user: dmestas tags: sub-manifest

19:29
Sub-manifest: card alphabet, artifact type, and the specification's vectors

Adds S as the twenty-second card letter and SUBMANIFEST as the ninth artifact type, per draft-fossil-submanifests-00 sections 3 and 4. Type determination reassigns after the card scan rather than during it, so no type is committed while cards are still being read (section 9.1). Reading and classification only: no tree walk, no emission.

The draft's seven appendix vectors ship alongside, each named by the SHA3-256 the draft states and checked by a test that recomputes it. check-in: 9b8bad477a user: dmestas tags: sub-manifest

2026-07-25
14:51
Release zit 0.1.3

A serving release over 0.1.2. Where 0.1.2 made a large repository cloneable, 0.1.3 makes serving one fast and lean.

A server against a large repository comes up immediately: the listener opens before the repository is read, so a client queues in the accept backlog while the server primes rather than being refused during a startup a large store made long. A push no longer stalls inside its own reply deadline; the snapshot rebuild it triggers now runs between connections. Serving reads the repository through an on-demand log-offset index rather than a resident copy, so peak memory to serve a 67,499-artifact repository fell from about 157 MB to about 32 MB, with clones byte-identical to before.

Also: the end-to-end suite is safe to run concurrently with itself (fresh loopback ports), and the unclassifiable-artifact tolerance rule is now covered by deterministic simulation and an end-to-end scenario. check-in: a469147254 user: dmestas tags: trunk, version-0.1.3

13:39
update: cover the unclassifiable-artifact rule with a DST sweep and an e2e

The rule that lets state assembly skip a stored artifact it cannot classify — when the artifact's bytes verify against the name it is stored under — shipped with the fix but was pinned only by two unit tests over hand-built logs, and by a manual clone of the real fossil repository. Neither the simulator nor the end-to-end suite exercised it, so a regression could pass every automated gate.

Deterministic simulation grows an artifact-level generator (src/sim/ assembly_sweep.zig): it emits artifacts whose card sets sit in and around the required-card table — some verifying against their stored name, some stored under a wrong one — and asserts the split directly, that assembly completes iff every unclassifiable artifact verifies, derives no state from one, and stays fatal on a single artifact whose bytes do not match its name. It runs every seed. assembleState is made public for it to drive; its body is unchanged.

The e2e suite gains a scenario that clones a served store carrying an anonymous wiki artifact (cards D L W Z, no U — the shape that first surfaced this) and asserts update tolerates it, with the read verbs and assembly agreeing on the same store. Reverting the verify-then-skip arm to an unconditional failure reddens the sweep, the e2e scenario, and both unit tests. check-in: c2851f933e user: dmestas tags: trunk

13:37
serve: read the served store through a log-offset index, not a resident copy

Priming a serve snapshot copied every artifact's container out of the log into an in-memory store and held them there for the server's lifetime. The log had already been scanned and every frame CRC-checked to build the rid table, so the copy was a second full read whose only product was resident bytes: on a 254k-artifact repository it was two thirds of the prime time and a few hundred megabytes of RSS, all to re-hold what the log already holds.

The snapshot is now a log-backed store: an index of hash -> log location, and container bytes read from the log on demand as a round emits them, memoised only for the round and released after it. Persisted order is preserved so the clone pager still cursors correctly, the binary-digest to hex-wire-name conversion stays at its one site, and a read fault after the scan surfaces as a server storage error so a clone is never silently truncated. The old whole-store copy is retained only where it is actually needed: the pull client's holdings and the equivalence test's oracle.

Measured, 67499-artifact repository, ReleaseSafe: a clone off the indexed serve is byte-identical to a clone off the copy-based serve (both materialise all 983 files of trunk identically), while serve peak RSS falls from 157 MB to 32 MB. The one remaining O(store) cost, the startup scan itself, is a separate change. check-in: e17845e40d user: dmestas tags: trunk

01:17
Create new branch named "slice-serve-b" closed check-in: e88fa2b9ed user: dmestas tags: slice-serve-b
00:21
serve: open the listener before priming; move the post-push rebuild off the request path

zit serve built its whole in-memory snapshot of the repository before it opened the listener socket. The build is O(store), so past a few thousand artifacts it outlasted the client's readiness probe and the client reported the server never came up — the store could be served, just never reached.

Bring-up now binds first: listen, announce, then prime, then accept. A client that connects during the prime queues in the kernel accept backlog instead of being refused, so the readiness window starts at the bind rather than after the scans. The listener open moves ahead of BOTH O(store) reads on the path — the config read and the prime. The connection deadline is armed only on accept, so priming never falls inside it. On a 254k-artifact store a connect now completes in ~2 ms against a ~5 s prime.

A push made the snapshot stale, and the old code rebuilt it inside the pushing round's own reply deadline — an O(store) rebuild charged to the request that caused it. The rebuild moves to a maintain hook the accept loop runs between connections, with no connection open to charge it to; the append-only stat gate makes the stale snapshot self-correcting until then.

The reorder is inherently a large-corpus latency property: reverting it leaves every unit test green, because the failure only shows when prime latency exceeds the readiness window. It is pinned in the milestone tier instead, where smoke already lives: zig build bench -- serve synthesizes a store, spawns the real server, and asserts time-to-first-connect is a small fraction of the prime (fixed order ~0.1; reverted order jumps past 1.0). The de-race and the accept-loop hook are pinned by ordinary unit tests. check-in: de218fa6f6 user: dmestas tags: trunk

2026-07-24
16:45
e2e: give the push and disconnect scenarios their own loopback ports

The 'cli push' and disconnect-robustness scenarios bound a fixed loopback port, so two e2e binaries running at once — the ordinary case under a parallel build or a busy CI runner — collided on it and one push exited non-zero, failing an unrelated-looking assertion. The failure was invisible because the assertion reported only the child's exit code and discarded its stderr.

Each scenario now draws a fresh port from the kernel (bind :0, read back the assigned port, release) and waits for the child to accept before driving it, the same approach freshLoopbackPort takes for the Fossil smoke tests. The push assertion now surfaces the child's stderr on failure, so the next divergence is diagnosable rather than merely counted.

Verified on Linux under the exact condition that reproduced the flake: two 'zig build e2e' runs in parallel, ten rounds, x86_64 and aarch64 — 20 of 20 green on each, where the fixed-port version failed intermittently. check-in: b4a5886252 user: dmestas tags: trunk

14:42
Create new branch named "slice-serve-coverage" closed check-in: 12dd6240d7 user: dmestas tags: slice-serve-coverage
2026-07-23
17:02
Repository State draft: carry the unclassifiable-artifact clarification

Regenerated from the RFC suite after the erratum repo-state-00-s4-unclassifiable-artifact was applied. New §4.4 states what assembly does with a stored artifact classification rejects: an artifact whose octets hash to the name it is stored under is opaque to repository state and MUST NOT fail the assembly, while one whose octets do not hash to its name is a distinct integrity failure and MUST be rejected. The remaining §4 subsections renumber behind it.

The draft was previously silent on this, and that silence was the trap: an implementation conforming to both drafts could reject an artifact per the required-card rule and then treat that rejection as fatal, which made the canonical fossil repository unassemblable. src/update.zig implements the rule this text now carries. check-in: 42e1faa9fc user: dmestas tags: trunk, version-0.1.2

16:36
Release notes for 0.1.2

The 0.1.2 release check-in carried the version bump and the README pointer but not the notes themselves: the add that would have tracked them was refused by a locked checkout database, and the refusal was hidden behind a pipe that kept only the last line of output. The file existed on disk and in no commit. check-in: aabc10268a user: dmestas tags: trunk

16:17
Release zit 0.1.2

Two defects made zit unusable against a repository of real size and real history. Serving a sync round cost work proportional to the served store rather than the request, so past a few thousand artifacts a round outran the connection deadline and the server answered nothing at all. Repository-state assembly rejected an entire repository over one stored artifact it declined to classify, while every read verb skipped the same artifact.

Together they meant a clone of the canonical fossil repository could be fetched but not used. It now clones in 67 rounds and materialises all 983 files of the check-in, each byte-identical to fossil's own checkout of it. check-in: 800caecbf2 user: dmestas tags: trunk

16:08
Merge fix10-tolerance: state assembly tolerates what it cannot classify

A conforming repository contains artifacts a conforming parser declines, and assembly rejected the whole repository over one of them while every read verb skipped it. Bytes that hash to the name they are stored under are stored correctly and merely unclassifiable, so they are opaque to state and skipped; bytes that do not are an integrity failure and stay fatal.

This closes the tolerance half of the update-diagnostics work. On a clone of the canonical fossil repository, update now materialises all 983 files byte-identical to fossil's own checkout. check-in: 6a0f1f3f66 user: dmestas tags: trunk

16:06
Tolerate a stored artifact state assembly cannot classify

A conforming repository contains artifacts a conforming parser declines. The artifact grammar requires the U card on a wiki artifact; fossil elides it when the submitter is "nobody", stores the result and syncs it. A clone of the canonical fossil repository therefore carries such artifacts, and assembly rejected the whole repository over one of them while timeline, ls, info and branch read the same store correctly.

The two conditions are separated by the name. Bytes that hash to the name they are stored under arrived and were stored correctly, so the only event is that this reader's grammar does not admit them; the artifact is opaque to state and is skipped, exactly as a non-participating type already is. Bytes that do not hash to their name are an integrity failure and stay fatal. Verification runs before the skip, which is what keeps the second from being swallowed by the first.

On a clone of the fossil repository, 67499 artifacts: update went from exit 1 having materialised nothing, to materialising all 983 files byte-identical to fossil's own checkout of the same check-in. closed check-in: f4736670a8 user: dmestas tags: fix10-tolerance

14:58
Merge fix9-serve-stall: bound a sync round by the request, not by the store

Two costs made a clone of a large repository O(rounds x store) of work; past a few thousand artifacts a round outran the connection deadline and the server answered nothing at all.

The reply's advertise walk classified every rid by calling content(), which INFLATES a wire-backed entry, so announcing the store cost its uncompressed size every round. It now reads held(), answering from the retained form alone. The send gate still reads content(): uploading composes from plaintext.

ServeCtx caches the snapshot and rebuilds only when a stat shows the log grew, so a commit made alongside a live server is still advertised next request. That rebuild is O(store) and stays out of every client's budget: serve primes before it listens, and a round that appends rebuilds before returning. Reuse needs isolation, since the responder interns phantoms as it answers, so a new OverlayStore gives each round a write overlay. Inflating memoises, so a snapshot outliving its round accumulates the whole repository's plaintext; it is released per round.

The one conflict, at the tail of src/cmd/sync.zig, was two independent test appends; resolved as the union. check-in: 6763d35da7 user: dmestas tags: trunk

14:51
Merge fix10-update-diag: split the update read-path diagnostics from the corruption claim

update, open, merge and bisect collapsed ApplyError.StateRecordInvalid and ApplyError.Corrupt onto one line, "cannot read repository: repository is corrupt". Corrupt means stored bytes do not match the name they are stored under. StateRecordInvalid means only that a stored artifact was not usable as a check-in state record, which says nothing about integrity. An intact fossil clone reaches the second routinely, so update accused an undamaged clone of corruption while timeline read the same store fine.

StateRecordInvalid now carries its own reason text, shared across the four verbs so they cannot drift or re-merge. The bisect mapper had a blanket else arm swallowing the whole set into the corruption line; it is now exhaustive.

Diagnostics only: assembly still rejects these artifacts, so update still exits 1 on such a clone. The tolerance fix is separate; the shape that triggers it, an anonymous wiki page with no U card, is pinned. check-in: e3bbb4ff22 user: dmestas tags: trunk

13:06
Serve: bound a sync round by the request, not by the store

Two costs made a clone of a large repository O(rounds x store) of work; past a few thousand artifacts a round outran the connection deadline and the server answered nothing at all.

The reply's advertise walk classified every rid by calling content(), which INFLATES a wire-backed entry, so announcing the store cost its uncompressed size every round. It now reads held(), answering from the retained form alone. The send gate still reads content(): uploading composes from plaintext.

ServeCtx caches the snapshot and rebuilds only when a stat shows the log grew, so a commit made alongside a live server is still advertised next request. That rebuild is O(store) and stays out of every client's budget: serve primes before it listens, and a round that appends rebuilds before returning. Reuse needs isolation, since the responder interns phantoms as it answers, so a new OverlayStore gives each round a write overlay.

Inflating memoises, so a snapshot outliving its round accumulates the whole repository's plaintext. It is released per round. Four clones of 3001 artifacts, ReleaseFast: 12 MB and 0.09 s, against 26 MB and 0.15 s. closed check-in: e2e9233582 user: dmestas tags: fix9-serve-stall

11:53
Split the update read-path diagnostics apart from the corruption claim

zit update, open, merge and bisect collapsed ApplyError.StateRecordInvalid and ApplyError.Corrupt onto one line, "cannot read repository: repository is corrupt". They are different claims. Corrupt means stored bytes do not match the name they are stored under. StateRecordInvalid means only that a stored artifact was not usable as a check-in state record, which says nothing about integrity.

An intact fossil clone reaches the second routinely: fossil stores and syncs artifacts it declines to crosslink itself. Assembly treats those as fatal while the read verbs skip them, so update accused an undamaged clone of corruption while timeline read the same store fine.

StateRecordInvalid now carries its own reason text, shared across the four verbs so they cannot drift or re-merge. The bisect mapper had a blanket else arm swallowing the whole set into that corruption line; it is now exhaustive over ApplyError.

Diagnostics only: assembly still rejects these artifacts, so update still exits 1 on such a clone. The tolerance fix is separate; the shape that triggers it, an anonymous wiki page with no U card, is pinned. closed check-in: f75dfdfbe7 user: dmestas tags: fix10-update-diag

10:59
Serve: answer a sync round in time bounded by the request, not the store

zit serve rebuilt its whole in-memory snapshot from the log on every request, and classified held-vs-phantom by calling content on every rid, forcing a full inflate of the entire repository per request. A clone cost O(rounds x store) of server work; past a few thousand artifacts one round outran the connection deadline and the server answered nothing at all.

ArtifactStore grows heldCount, the aggregate of the existing non-inflating held predicate, and serve counts with it rather than re-deriving a worse predicate caller-side. ServeCtx caches the snapshot and rebuilds only when a stat shows the append-only log grew, so a commit made alongside a live server is still advertised on the next request.

Reuse needs isolation: the responder interns phantoms and upgrades entries as it answers, and that would change what later rounds serve. A new OverlayStore answers each request over a per-request write overlay, reads falling through, writes confined to the overlay. The persist pass now takes an ArtifactStore.

On 67,499 artifacts: unfixed, no round completes; fixed, one snapshot build then sub-millisecond rounds. check-in: 2874978461 user: dmestas tags: fix9-serve-stall

2026-07-20
22:00
Docs: track 0.1.1 release notes and ignore local banks check-in: 0358b7df66 user: dmestas tags: trunk
21:26
Release zit 0.1.1

Harden native Linux filesystem behavior, deterministic repository iteration, update and undo transitions, Fossil smoke-process containment, and the three-platform CI matrix. check-in: be97df0398 user: dmestas tags: trunk, version-0.1.1

10:38
README: point to the release notes, name the coverage step

The limitations table stopped short of where a reader would look for more detail; it now points at the release notes rather than growing into a second copy of them. The named build steps were missing zig build cov, the kcov coverage report over the test suite. check-in: 7f7828b7d6 user: dmestas tags: trunk, version-0.1.0

10:13
Release notes: drop the CI-runner clause on the fossil version

The notes named the fossil version smoke passed against, which is a legitimate conformance fact, but then explained CI runner provisioning and told a future editor to reconcile it against a workflow log. That is tooling detail with no place in a document meant to outlive the tooling. The fossil version stands alone as the fact that matters. check-in: 07c840ae4b user: dmestas tags: trunk

05:50
Stamp the release version 0.1.0

The package version and the version the binary prints move from the 0.0.0 development placeholder to 0.1.0, the first tagged release. Both sites carry the same string so the manifest and the running binary never disagree about what this is. check-in: 9e0be2fc75 user: dmestas tags: trunk

05:42
Merge h10-ties: the simulator now generates the equal-mtime ties that hid B1

The generator only ever emitted strictly increasing, unique modification times, so the equal-second corner -- where two check-ins tie and the name must resolve by DAG ancestry, not hash order -- was covered only by hand-written regression tests. That corner was a real bug once. A new tie mode reuses the previous mtime on a seeded, bounded roll and shapes a reused-mtime check-in as a descendant chain, the exact shape the bug took; the oracle resolves a tie the way production does, by ancestry and then least hash, so a resolver that fell back to hash order is caught. Order comparison is done up to that tie-break, with the two genuinely implementation-defined dimensions skipped under ties and documented. Reverting the real tie-break in a scratch build makes the tie sweep fail where the unique sweep stays green -- proof the simulator now sees the class it was blind to. Default seeds draw no extra entropy, so the existing universe is unchanged. check-in: b354fb5947 user: dmestas tags: trunk

05:33
H10: fuzz equal-mtime ties in the repository-state DST

sim/gen.zig emitted strictly-increasing unique mtimes, so the same-second selection tie -- once a real defect, the B1 regression cluster -- was only example-tested, blind to a class that already bit once.

gen.zig gains a tie-permitting mode: a seeded roll reuses the previous mtime, bounded to short runs, and each reused-mtime check-in is shaped as a linear child of the previous one, so an equal-mtime run is a real descendant chain. oracle.zig now computes the documented tie-break in resolveName, tipSlot and openLeafOf -- descendant-before-ancestor over full ancestry, then least artifact hash (repostate.zig ancestryMaximal) -- so a resolution reverting to hash order is caught, not mirrored.

dst.zig runs the tie sweep as additional coverage; the unique sweep is unchanged. Order-independence keeps its total-rule dimensions (leaf set, name resolution) under ties and skips the open-leaf set, whose equal-mtime supersede is implementation-defined (Section 5.3); the Section 6.3 branch representative is first-seen under a tie, checked in the unique sweep only.

Reverting the tie-break to hash order trips the tie sweep at seed 1. closed check-in: c52cc8cc76 user: dmestas tags: h10-ties

04:52
Merge h7-degraded: prove sync converges on a wire that never heals

Phase 2 only ever proved convergence after the network was fully healed, so a protocol that converges on a perfect wire but livelocks under sustained loss would have passed. A new degraded phase holds a fixed, never-healed drop and duplication rate on both links and drives a fresh session through the same safety, convergence and termination oracles, within a window scaled to the loss rate: a round makes progress only if both its request and reply arrive, so the expected round count grows as one over the delivered-fraction squared, times a safety factor, with the derivation in a recomputable comment. It converges with wide margin -- across many seeds the worst run finished in a small fraction of the window at every rate tested -- so there is no livelock. Pinning the window below the real need trips the oracle, so the check is not vacuous. The gate runs one modest rate; the soak sweeps a range. check-in: b8ef4a68e5 user: dmestas tags: trunk

04:43
Compute the degraded liveness window without catch unreachable

The loss-scaled convergence window used divCeil with a catch unreachable for the divide-by-zero arm, which the precommit guard flags outside tests. The delivered-percent denominator is provably at least one, so plain integer ceil division gives the same bound with no error union. closed check-in: 0fbc9225bf user: dmestas tags: h7-degraded

04:40
Merge h11-serve-socket: a stalled client can no longer wedge the server

zit serve accepts one connection at a time and read its request head and body with no deadline, so a single peer that opened a connection and then dribbled or withheld its request pinned the accept loop and starved every other client -- a real availability gap the in-memory fuzzers could not see, since they never touch the socket. The connection now runs under a whole-connection deadline: the request work races a timed sleep, and a peer that misses the deadline is dropped so the loop moves on. The bind is loopback only and the deadline is generous, so a legitimate request -- even at the reply ceiling -- always finishes far inside it; only a stalled peer is shed. Four end-to-end abuses -- a slow-loris head, a half-sent body, a mid-response disconnect, an idle-connection burst -- each prove the server still answers a fresh request and a real clone still converges, and the fix is shown to be load-bearing by wedging with it removed. A per-read idle timeout and a configurable value are left for later. check-in: 56c17d0675 user: dmestas tags: trunk

04:37
Prove sync liveness under a degraded, never-healed wire

The sync sweep proved convergence only after the network fully healed, so a protocol that converges on a perfect wire but livelocks under sustained loss would still pass.

Phase 2b runs a fresh session over a wire held at a fixed drop/dup rate, never healed, asserting the same convergence and termination oracles within a window scaled from the loss rate: expected rounds are the clean window over (1 - p_loss) squared per direction, times a safety factor, recomputed in a comment rather than a magic number. No corruption is injected -- a corrupted body is already a lost round.

The protocol converges comfortably: over 200 seeds the worst run took 7, 13, and 18 rounds at 10, 25, and 40 percent loss, against windows of 160, 228, and 356 -- no livelock. The gate joins at 10 percent; the soak sweeps the wider rate set. Pinning the window below the real need trips the liveness oracle, so the check is not vacuous. The liveness-under-faults discipline follows TigerBeetle's VOPR. check-in: 464b82c888 user: dmestas tags: h7-degraded

04:34
serve: bound each connection so a stalled client cannot wedge the server

zit serve accepts one connection to completion before the next (single-threaded, serve.zig), and receiveHead plus the body read blocked with no deadline. A loopback peer that dribbles a request head, half-sends a body, or connects idle and holds could pin the accept loop forever -- the socket read path the exchange fuzzers never reach, covered before only by happy-path interop.

Add a whole-connection deadline: the request work runs on its own unit of concurrency raced against a timed sleep, and draining the loser cancels the blocked read, freeing the loop. A per-read SO_RCVTIMEO is unusable here (the Io backend treats a socket-timeout EAGAIN as a bug). serve binds 127.0.0.1 only, so a legitimate same-host request finishes in milliseconds even at the 512 MiB body ceiling; the 5 s bound never fires on a real client and only sheds a stalled one.

Four end-to-end scenarios abuse a live server over loopback -- dribbled head, half-sent body, mid-response disconnect, idle-connection burst -- and assert with bounded waits that a fresh request is still answered and a clone still converges (sync 3.7). closed check-in: e87fb840d1 user: dmestas tags: h11-serve-socket

04:28
Merge h2-checkout-dst: crash-test the working tree, and a finding it surfaces

The working tree -- the one place a version-control tool can lose a user's data -- had no crash simulation; only the log store did. A new faulty directory layer tears and freezes writes at a seeded crash point, and a sweep replays an update across every crash cut, reopens, and checks four invariants. Three hold: an untracked file the update must preserve is never touched, a crashed update recovers to its target through the undo journal, and the checkout record never names a check-in whose tree did not finish materializing. The fourth does not, and that is the point of the harness: the payload write lands in place rather than through the write-temp-and-rename the metadata already uses, so a crash mid-write can leave a managed file spliced from old and new bytes -- torn, but recoverable. The gate runs the three that hold; the fourth is pinned by a test asserting the splice IS produced, so it flips the day the write is made atomic. Crash-point enumeration follows TigerBeetle's storage model. check-in: 5401ce245f user: dmestas tags: trunk

04:19
Merge h3-crashsync: run a sync over a store that can tear, across crashes

The storage and network simulations ran off the same seed but never intersected, so no simulated exchange ever crossed a store that could lose its tail. This composes them: the sync client's content is backed by a real log over a tearable file, and between rounds -- on a seeded coin -- the store is crash-restarted mid-persist, reopened to its recovered prefix, and the session resumed with a fresh client. The existing safety and liveness oracles must survive the composition: no corrupt content accepted, the recovered set never exceeds what was held before the crash, and once both the network and the disk heal a fresh session still converges within the bounded window. Green across the gate and a deep 120-seed run; a retained switch that makes recovery accept a torn frame trips the oracle, so the check is not vacuous. The fault-composition follows TigerBeetle's VOPR. check-in: 9b689ebafe user: dmestas tags: trunk

04:11
Merge h9-fuzzcov: every fuzz target now guards its own reach

Only two targets asserted that their generator still reaches the interesting outcomes; the other 25 could rot to instant-rejection and pass every no-crash check while testing nothing. Six targets that split into distinct outcomes now carry a reached-outcomes floor above a measured minimum round count -- decode-bounded, target-size, predicates, adversarial sync parsers, logstore open, and unframe each must reach both their accept and reject arms, or the run fails. Thresholds are measured at a fixed seed and re-checked at a second, and each floor is proven to trip when the generator is biased to one arm. Single-outcome round-trip targets carry a one-line exemption instead of a vacuous assert. The measurement also named several generators that under-reach their target's arms; those are recorded for follow-up, not fixed here. check-in: 57d8994848 user: dmestas tags: trunk

04:00
Merge h5b-verbasserts: TigerStyle asserts on the cmd verb layer

The companion to the library assert pass, now on the two split verb files. Eighteen asserts stating invariants that already hold: in the read verbs (cmd/history.zig), that a name decodes to a full digest before it is rendered and that a windowed or -n-capped listing's shown count matches the walked set; in the mutating verbs (cmd/checkout.zig), the preconditions at each destructive entry -- a full valid target id before update, both bisect endpoints present, exactly one pending merge or special op, every revert target in the managed set, and the checkout-record and managed-set agreement commit assembles a manifest from. Asserts only, no behaviour change; all sit on paths the e2e and simulation sweeps already drive, and all survive them. No assert fired, so no finding. check-in: 58763bb760 user: dmestas tags: trunk

03:55
cmd: TigerStyle assert-density pass over the verb layer

Mirror the H5a assert idiom (state invariants that already hold, no behavior change) across the split read and mutating verbs. Asserts only; wire output and exit codes unchanged.

history.zig read verbs: - decodeFullId: a validated name decodes to a whole 20/32-byte digest, never a truncated prefix. - walkLog: every checkin/phantom line renders a full valid name, and the -n cap (parseLimitValue maps 0 to null) never overruns. - stat --artifacts / timeline: the listing projects the walked rid set, is capped by -n, and the printed count matches it. - tag add: the mint targets a check-in by its full artifact id.

checkout.zig mutating verbs: - update/amend: the applied/amended target is a full artifact id. - bisect: both endpoints present before the span move. - merge: exactly one pending op (merge_parent XOR special_op) before applyMerge; revert restores only managed paths. - commit: checkout record and managed set agree before the manifest assembles; root has no pending op, merge and special exclusive, a child's parent resolved, every captured card tracked. closed check-in: 99e0c73447 user: dmestas tags: h5b-verbasserts

03:53
Merge h4-interop: bidirectional fossil interop smoke, push side included

The interop smoke only ever went one way -- fossil confirming what zit authored, always pull or clone direction. The push side of the wire, where capability negotiation and igot/gimme ordering live, was never exercised against the reference. Two scenarios close it, same policy as before (zit authors every byte; fossil only confirms; a mismatch prints the diff and its RFC citation, exits nonzero, and is a ledger decision). zit pushes a grown check-in to a live fossil server and a fresh fossil checkout must reconstruct the file byte for byte; then fossil commits a zit-authored blob that zit pulls and reads back through the library, re-verifying content against its artifact name. The smoke log now opens with the exact fossil version each run, so the conformance claim always names what it was confirmed against. check-in: 3ad0bf90da user: dmestas tags: trunk

03:49
Smoke: push-direction fossil interop and a recorded fossil version

Every smoke scenario drove pull/clone only -- fossil pulling from zit, zit pulling from fossil -- so zit's push wire (its igot/gimme ordering and capability negotiation as the sync client uploading to a server) never met the reference implementation.

Two scenarios in src/smoke.zig close that gap under the file's standing policy: every compared byte is zit-authored, fossil only confirms. S7 has zit push a grown check-in to a live fossil server, then reads it back from a fossil checkout byte-for-byte (sync 3.3/3.7). S8 lets a fossil commit extend the shared repo with our bytes, has zit pull it, and confirms convergence through the library reader -- Repository.blob re-verifying content against its artifact name (sync 3.2/3.7, artifact 7.2). Both share a prelude that seeds a zit repo, has fossil clone it for a common project-code, grants the anonymous loopback sync user check-in, and disables the autosync the clone inherits.

Smoke also prints the fossil version verbatim at the head of its log, so the exact reference implementation the scenarios confirmed against is recorded wherever smoke runs. closed check-in: a2672203c0 user: dmestas tags: h4-interop

03:41
Merge h1-soak: the out-of-band deep soak the harnesses assume exists

The fuzz and simulation harnesses defer real depth to a nightly runner that was never written; the gate only ever ran a shallow smoke. scripts/ soak.sh is that runner. In a ReleaseSafe build it runs the deterministic simulation over many seeds and long step counts, then every fuzz target -- enumerated from the runner's own usage so the list cannot drift -- under an equal wall-clock budget per target, so a cheap target reaches far more rounds than an expensive one with no second cost table to maintain. A failure appends a runnable reproduce line to a dated log, drops a flag file, and exits nonzero; it never files or fixes anything, and its logs live outside the checkout. Proven to catch both a seeded simulation regression and a seeded fuzz regression, each replaying from the logged line. Installing the schedule and the first full nightly are operator steps, documented in the script and its README. check-in: 1b447f8b47 user: dmestas tags: trunk

03:36
Add scripts/soak.sh: out-of-band deep DST + fuzz soak runner

The pre-commit gate runs the DST and fuzz harnesses shallow. Every comment deferring depth to an out-of-band runner pointed at a script that did not exist; scripts/ held only mirror-export.sh. This is that runner.

On a ReleaseSafe build (asserts on) it runs the repostate+sync DST at a deep seed/step sweep, then every fuzz target enumerated from the runner's own usage output, so the list cannot drift, under an equal per-target wall-clock budget. --rounds grows from measured speed to fill each budget, so a cheap target sweeps millions of rounds while a pack round-trip sweeps thousands.

On the first failure it appends a runnable reproduce line to a dated log, drops a flag file, and exits non-zero; it never files and never fixes. It owns an explicit fresh seed per fuzz invocation, because the fuzz runner's buffered stderr is dropped on the error-return path: a crashing target prints only its error name, not the seed it flushed before running.

Logs and the flag live outside the repo. Installing the schedule and the first full nightly are operator actions, documented in the header and soak.README. closed check-in: 9f00c44a8f user: dmestas tags: h1-soak

03:34
Merge h8-readrot: simulate latent read rot, prove the read path detects it

The fault simulator only ever corrupted at crash and restart boundaries. It could not model a latent sector error: bit rot that surfaces during a read of a store that opened clean. A new read_rot_pct knob does that -- a read returns payload-derived, never-self-healing corruption for a frame that is intact on disk -- and a sweep reopens a healthy store, arms it, and reads every held hash. The oracle: each read returns exactly the stored bytes or a declared corruption error, never wrong bytes as success. The append open path re-runs the whole-frame checksum per read, so it catches the rot; the sweep is green and a companion test proves the sweep can fail by reading through the checksum-skipping read-only open. The knob is inert at zero, so every existing seed keeps its byte stream. The crash-point fault technique follows TigerBeetle's storage fault model. check-in: ba805e5913 user: dmestas tags: trunk

03:29
Add online read-fault injection to the storage DST sweep

FaultyFile injected corruption only at crash/restart boundaries. Add a read_rot_pct knob: a read of an intact byte interns one offset in its span as permanently corrupt (payload-derived mask, never self-heals) -- a latent sector error surfacing at content() time on a store that opened clean, independent of any crash. A healthy disk draws nothing, so existing sweeps keep a byte-identical PRNG stream.

New storage_sweep.readRotSweep joins the dst gate: after a healthy open, every content-bearing hash reads back either its exact bytes or a declared corruption error -- wrong bytes presented as success is SilentCorruption. The append open path re-runs each frame's whole-frame CRC per read, so latent rot is detected and the gate stays green. The verified-on-open read path (openReadOnly) trusts the scan-time CRC on an immutable-length assumption that bit rot violates; a test drives that path to prove the oracle fires SilentCorruption.

Read-fault model follows TigerBeetle's storage-fault-injection lineage. closed check-in: fde61b03b2 user: dmestas tags: h8-readrot

03:27
Merge h5a-asserts: TigerStyle assert pass on update, serve, transport

Assert density was collapsing exactly where the working tree and the wire are touched: update.zig held 2 asserts across 2127 lines, serve.zig 3, transport.zig 2 -- against the hundreds in the artifact and sync cores. This pass adds 17 asserts stating invariants that already hold: the checkout-state transitions in update (managed-set, record and rollback image agreement around each applyCheckin stage), the request/response postconditions in serve (media, arity and body-ceiling), and the frame, redirect and body-cap postconditions in transport. Asserts only, no behaviour change; where a result had to be named to assert on it the error mapping is preserved exactly. All survive the deterministic simulation sweeps, which is the point of landing them before the checkout crash-recovery sweep that will hammer update. check-in: 9654d06838 user: dmestas tags: trunk

03:21
Assert-density pass: update, serve, transport

Raise assert density in the three thinnest library modules, enforcing invariants that already hold. Asserts only, no behavior change.

update.zig: guard the checkout-state transition. applyCheckin asserts the desired managed set equals the target plan membership (validateTopology proves the paths unique); requireClean asserts a clean tree's plan membership equals the recorded managed set; recordUndoImage asserts the affected union covers both operands; commitMaterialization self-guards that the committed record names exactly the staged check-in and that the managed set is its membership; the merge path asserts one rollback pre-image per path and a shared pre-merge managed baseline.

serve.zig: a servable request is a POST bearing a request-valid compressed media type (§4 L759-764); an accepted body honors the §4 L1148-1149 ceiling; a compressed reply is application/x-fossil (§4.1); the accept loop honors its request bound.

transport.zig: a framed container carries its 4-byte length prefix (§4.1); an unframed reply stays within reply_body_cap (§4 r9); a followed redirect lands only on an http/https transport (§4 r12/r13). closed check-in: c9fb87f554 user: dmestas tags: h5a-asserts

03:06
Merge s5-checkout: extract the checkout verb family, completing the split

Fourth and final family extraction. A pure behaviour-preserving move: the mutating checkout verbs -- update, bisect, merge, revert, undo, redo, commit, amend -- and the helpers private to them (the undo journal, the dirty-tree gate, the checkout record, the commit and amend manifest assembly) move verbatim into src/cmd/checkout.zig, byte-identical to their source but for the pub the dispatch forces on the eight handlers. commitEpoch stays in main.zig because main itself builds the ambient clock from it; shared helpers stay pub and aliased. With this, main.zig holds only main, the verb dispatch, init, the shared helpers and the test surface -- the eleven-thousand-line file is now four cmd families plus a thin dispatch core. No helper duplicated, no cross-cmd import, test count unchanged. check-in: ae54e47b3c user: dmestas tags: trunk

02:59
Extract the mutating checkout verb family into src/cmd/checkout.zig

The final family extraction, completing the split of the CLI surface out of main.zig. A pure behaviour-preserving move: the mutating verbs -- update, bisect, merge, revert, undo, redo, commit, amend -- and the helpers and tests private to them move verbatim into src/cmd/checkout.zig, byte-identical to their source but for the pub the dispatch switch forces on the eight dispatched handlers.

The move is non-contiguous: the update error mapper, the digest wire helpers, mintControlArtifact, and commitEpoch stay in main.zig because they are shared with other verbs or with main itself, so moving them would cross a cmd boundary or duplicate them. Shared helpers stay pub and are aliased so the moved bodies read unchanged. No helper duplicated, no cross-cmd import, test count unchanged.

After this, main.zig retains only main, the verb dispatch, runInit, the genuinely shared helpers, and the test helpers. closed check-in: 9870033d32 user: dmestas tags: s5-checkout

02:33
Merge s4-history: extract the read verb family into src/cmd/history.zig

Third of four family extractions. A pure behaviour-preserving move: the read surface -- stat, log, leaves, timeline, branch, tag, info, ls, cat, artifact, annotate -- and the name-resolution and rendering helpers private to it move verbatim into src/cmd/history.zig, byte-identical to their source but for the pub the dispatch switch forces on the eleven handlers. The extraction is non-contiguous: the mutating checkout verbs interleave with the read verbs in the file and stay exactly in place. repoReadFail stays pub in main.zig because the sync family calls it, so moving it would cross cmd boundaries. Shared helpers stay pub and aliased so moved bodies read unchanged. No helper duplicated, no cross-cmd import, test count unchanged. check-in: 3bbc572acd user: dmestas tags: trunk

02:24
Extract read/history verb family into src/cmd/history.zig (S4)

Move the read-only inspection verbs — stat, log, leaves, timeline, branch, tag, info, ls, cat, artifact, annotate — together with the name-resolution and rendering helpers private to them out of src/main.zig into the new src/cmd/history.zig, mirroring the S2 sync and S3 tracking extractions.

Pure behaviour-preserving move: every relocated declaration is byte-identical to its main.zig source. The only changes are the new import prologue and the pub forced on the eleven dispatch-called handlers. Helpers shared with the still-in-main checkout verbs (repoReadFail, openRepo, reasonOf, firstCommentLine, renderBranchList, lessByHash, mintControlArtifact, and the per-verb help strings) stay pub in main.zig and are aliased back in the prologue.

The extraction is non-contiguous: the checkout family — update, bisect, merge, revert, undo, redo, commit, amend — is interleaved among the moved verbs and is left exactly in place.

main.zig keeps the verb-dispatch switch, now calling cmd_history.runStat and its siblings; its test root imports the new file so the moved inline test keeps running. closed check-in: 8e2c31c8ba user: dmestas tags: s4-history

01:52
Merge s3-tracking: extract the tracking verb family into src/cmd/tracking.zig

Second of four family extractions. A pure behaviour-preserving move: the eight working-tree handlers -- add, rm, mv, status, extras, clean, addremove, diff -- and the helpers private to them, including the whole diff-and-compare machinery, move verbatim into src/cmd/tracking.zig, byte-identical to their source but for the pub visibility the dispatch switch forces on the eight handlers. init stays in main.zig because clone bootstraps through it, and stat stays too; both are called by more than this family. Helpers shared with staying verbs stay pub in main.zig, aliased so the moved bodies read unchanged. No helper duplicated, no cross-cmd import created, test count unchanged. check-in: 1df44c39bd user: dmestas tags: trunk

01:46
Extract the tracking verb family into src/cmd/tracking.zig

Release step S3, the second of four family extractions from main.zig. A pure behaviour-preserving move: the eight working-tree tracking handlers -- add, rm, mv, status, extras, clean, addremove, diff -- and the helpers and tests private to them move verbatim into src/cmd/tracking.zig, byte-identical to their source but for the pub visibility the dispatch switch forces on the moved handlers. Helpers shared with other verbs (runStat, commit, merge, revert) stay pub in main.zig and are aliased back so the moved bodies read unchanged. No helper is duplicated and no cross-cmd import is created; runInit and runStat stay in main.zig. The test count is unchanged, so nothing was orphaned. main.zig keeps main, the verb dispatch, and the shared helpers. closed check-in: ed32969b32 user: dmestas tags: s3-tracking

2026-07-19
23:57
Merge s2-sync: extract the sync verb family into src/cmd/sync.zig

First of four family extractions from main.zig. A pure behaviour- preserving move: the eight network and store-transfer handlers -- serve, clone, open, pack, pull, push, sync, remote -- and the helpers private to them move verbatim into src/cmd/sync.zig, byte-identical to their source but for the pub visibility the file boundary forces on the handlers the dispatch switch still calls. Helpers shared with other verbs stay in main.zig, aliased back so the moved bodies read unchanged. No helper is duplicated and no cross-cmd import is created; the test count is unchanged, so nothing was orphaned. main.zig keeps main, the verb dispatch, and the shared helpers. check-in: 47a2717952 user: dmestas tags: trunk

23:45
Extract the sync verb family into src/cmd/sync.zig

Move the sync / network / store-transfer verbs — serve, clone, open, pack, pull, push, sync, remote — together with the helpers and tests private to them out of main.zig into a new src/cmd/sync.zig. This is a pure behaviour-preserving move: every relocated line is byte-identical to its main.zig source; only cmd/sync.zig's import prologue and the handlers' pub visibility are new.

Helpers still shared with other verbs stay in main.zig, now made pub and aliased back in cmd/sync.zig so the moved bodies call them by their original unqualified names. The verb-dispatch switch stays in main.zig and calls into the new file; main.zig's test block imports it so the moved inline tests keep running.

No behaviour change: the full test count is unchanged and the dead-file guard reaches cmd/sync.zig through the dispatch import, with no allowlist entry. closed check-in: 254575cbd8 user: dmestas tags: s2-sync

23:18
Merge s1-tidy: a dead-file guard before the cmd/ split

Zig runs only the tests reachable from a root the build compiles. A source file no root imports still compiles -- its tests just stop running, and nothing fails. That is the defining hazard of the coming main.zig split, so the guard lands first. A test walks every src/**/*.zig, builds the import graph, and asserts each file is reachable from one of the three roots: root.zig, main.zig's test block, or dst.zig. The failure names the file and the one-line import that fixes it. Six files that build.zig wires as their own roots are allowlisted, and the list itself is checked so a stale or newly-reachable entry fails rather than hiding a real orphan.

The DeadFilesDetector pattern is borrowed from TigerBeetle's src/tidy.zig. check-in: 60b10a6332 user: dmestas tags: trunk

23:11
Add tidy dead-file guard over the src test surface

Zig runs only the tests reachable from a root the build compiles. A .zig file that no root imports still compiles -- its tests simply stop running and coverage evaporates with no error. tidy.zig walks src/**/*.zig, builds the @import graph, and asserts every file is reachable from one of the three test roots: root.zig (exported module), main.zig's test block (CLI surface), and dst.zig (harnesses). An allowlist carries the files build.zig wires as their own roots -- e2e, smoke, and the benchmarks -- each verified rather than papered over, and a hygiene check fails if an allowlist entry goes stale or becomes reachable. On a miss the failure names the file and the one-line import that re-roots it.

This lands before the main.zig to src/cmd split so that move cannot silently orphan a file's tests.

Mechanism borrowed from TigerBeetle's src/tidy.zig DeadFilesDetector, which walks its own tree and fails the build when a file falls out of the import graph. closed check-in: a8d7f6b621 user: dmestas tags: s1-tidy

22:41
Merge p5b-docs: 0.1.0 release notes and an honest limitations table

Adds the release notes as a shipped asset and a limitations section to the README, both stating plainly what 0.1.0 does not do: diff is name-status against the current check-in only, the sync server code is an informational placeholder while the project code is real, the R and W cards are carried but not interpreted, and stash/grep/ui/settings and rev-to-rev diff are absent. The notes record what the conformance claim rests on -- smoke driving the real fossil binary in CI -- and cite the pack measurements to their existing benchmark rather than restating them as fresh claims. The version stamp is left for the ship step. check-in: 7ba838ab6c user: dmestas tags: trunk

22:39
Merge p5a-help: pin that help names every dispatched verb

Help already listed all dispatched verbs, but nothing held it that way: a verb added later could ship without a help line and no test would notice. An end-to-end pin now drives off the Verb enum itself and fails, naming the offender, if any dispatched verb is absent from zit help -- so the audit cannot silently rot as the command set grows. check-in: 7a0735c415 user: dmestas tags: trunk

22:35
Merge iss7b: read a fossil-legacy baseline instead of refusing files/merge

The #7 fix taught the timeline read path to tolerate fossil's 2008 cvs2fossil-era check-ins, whose F cards carry a dangling trailing separator the strict grammar has no production for. The baseline read path had the same defect: parseBaseline used the strict parser, so files and merge on a delta whose baseline is one of those legacy manifests failed as an unresolvable baseline. It now reads through parseForImport, the same tolerance, and threads the normalized copy the manifest borrows into the owned baseline so it is freed exactly once. The strict parser still runs first and remains the sole grammar oracle; any other malformation is still refused. check-in: cd63fa1766 user: dmestas tags: trunk

22:30
Read fossil's legacy delta baselines instead of dropping their files

The files/ls path eagerly reads a delta check-in's B-card baseline and parsed it through the strict reference parser. fossil's own 2008 cvs2fossil-era manifests carry F cards with a dangling trailing separator after the hash -- a space where a perm token would go -- which the section 4.2 grammar has no production for, so the strict parser rejects them as corrupt. When such a manifest is a delta's baseline, the rejection surfaced as BaselineUnresolvable and the check-in's whole file set vanished.

The target-manifest read path already tolerates this shape through parseForImport; the baseline path now does too. The strict parser still runs first and stays the sole grammar oracle; tolerance engages only when a well-framed, Z-valid artifact is rejected, strips exactly one trailing separator per F card, re-seals the checksum, and re-runs the strict parser -- any other malformation is still refused. The tolerated normalized copy is owned by the parsed baseline and freed with it. closed check-in: 7bb5af134e user: dmestas tags: iss7b

22:20
notes: name -n's timeline/log limit, not only its dry-run preview

The command summary described -n as previewing the destructive verbs alone; it also caps timeline and log at the most recent N, and -R reads a repository with no working tree. Both are stated so the summary does not undersell what shipped. closed check-in: 80913fe845 user: dmestas tags: p5b-docs

22:13
Guard that every dispatched verb appears in `zit help`

Add an end-to-end check that runs `zit help` and asserts every member of the dispatched Verb enum is listed as a command, keyed off the enum itself rather than a hand-copied list. A verb wired into the dispatcher without a help line now fails the build instead of shipping undiscoverable.

The match is anchored on the command column, so a name that appears only inside another verb's description — "sync" in serve's line, "stat" within "status" — cannot satisfy it; a dropped command entry is caught by name.

The shipped help already names all verbs, so this changes no output; it pins that state against future drift. closed check-in: 64b9a0ee71 user: dmestas tags: p5a-help

22:10
docs: honest 0.1.0 limitations table and release notes

Add a Limitations section to the README and a docs/RELEASE-NOTES-0.1.0.md release asset, both re-derived from current shipped behavior rather than assumed.

The caveats are the deliberate deferrals, verified against the binary and source: diff is working-tree-vs-current-check-in and name-status only, with no rev-to-rev form; the sync server code is a fixed informational placeholder while the per-repo project code is real (derived on init, persisted, adopted on clone); the R and W cards are parsed but not interpreted, whereas T and U are acted on; and stash/grep/ui/settings have no equivalent.

The notes name the local fossil 2.28 that the interop smoke passes against, flag that the runner version lags and must be confirmed at ship, and cite the pack re-deltification measurements to their benchmark commit bab9303056. check-in: ab4951c2e8 user: dmestas tags: p5b-docs

21:52
Merge iss7: read fossil's legacy check-ins instead of dropping history

timeline stopped partway through a large fossil-origin repository and lost every check-in at and beyond that point. The cause was not an I/O fault, as the shared "input/output error" wording suggested, but a strict parse: fossil's own 2008 cvs2fossil-era check-ins carry F cards with a dangling trailing separator after the hash, which the §4.2 grammar has no production for and the reference parser rejects as corrupt. Repository.manifest -- the timeline read path -- used that strict parser, so the first such check-in and all older ones vanished.

It now reads through parseForImport, the same read-path tolerance the diff baseline already relies on. The strict parser still runs first and stays the sole grammar oracle; tolerance engages only when a well-framed, Z-valid artifact is rejected, strips exactly one trailing separator per F card, re-seals the checksum, and re-runs the strict parser -- so any other malformation is still refused. Complete history renders again, down to the repository's 2007 root. check-in: 4e8d402bb6 user: dmestas tags: trunk

21:36
timeline: read fossil-legacy check-in manifests instead of dropping them

Repository.manifest parsed check-in manifests through the strict reference parser, which rejects fossil's own 2008 cvs2fossil-era manifests: their F cards carry a dangling trailing space after the hash, where the §4.2 perm token (1*tchar) permits none. On a large fossil-origin repository this made `zit timeline` stop at the first such check-in and exit non-zero, silently losing that check-in and every older one — a functional loss of history, surfaced as a misleading "input/output error".

Parse through parseForImport, the same read-path interop the diff baseline already relies on: the strict parser still runs first and stays the sole grammar oracle; the trailing-separator tolerance engages only when an otherwise well-framed, Z-valid artifact is rejected. OwnedManifest now also owns the normalized copy the manifest borrows when that tolerance fires. closed check-in: b503efb9f2 user: dmestas tags: iss7

21:01
Merge iss12: -R/--repo and -n on the read verbs, in one pass

A read verb could only reach a store by walking up from the cwd, and timeline/log could only emit the whole history. -R/--repo names a store directly, so timeline, log, leaves, branch, tag, info, ls, cat, artifact and annotate run against it with no working tree; a verb that reads or writes the checkout refuses -R by name rather than silently operating on the cwd's store. -n N caps timeline and log at the N most recent check-ins (0 means all), ordered by the same DAG the full listing uses. The -n spelling stays --dry-run on add/rm/clean/addremove, a per-verb overload matching fossil, so those verbs reject -n N and keep their boolean sense.

stat is left out of the -R grant on purpose: it interrogates only the store, but its handler takes no redirected repository, so it refuses -R by name rather than read the cwd's store while appearing to honor the flag. check-in: 43468196c2 user: dmestas tags: trunk

20:50
Merge iss6: open --manifest writes the working-tree manifest files

Repositories that enable fossil's manifest setting expect manifest, manifest.uuid and manifest.tags in the checkout, and their builds read them to stamp a version. A zit checkout could not produce them, so such a build failed or, worse, succeeded with no version embedded. zit open --manifest writes them; the bare flag means ru, and a value picks any combination of r (the check-in artifact), u (its hash) and t (its branch and symbolic tags). The choice is recorded in the checkout record, so every later update refreshes the files; absent flag writes nothing. The selection is explicit rather than read from the cloned repository because the two places fossil keeps it can disagree and only one syncs, so a checkout reading the setting could diverge from a fossil checkout carrying a local override while claiming fidelity. Contents match fossil byte for byte, including the sterilizing comment it appends to manifest. check-in: b38b3be769 user: dmestas tags: trunk

20:46
Refuse -R/--repo on amend

amend rewrites history, but it was in the -R accept arm alongside the read verbs, so `zit amend -R STORE trunk --comment X` from any directory minted a check-in in the named store — a write to a redirected repository the operator never had a checkout of. That is the exact surprise the -R refusal exists to prevent, and amend is not one of the authorized read verbs.

Move amend into the reject arm so -R on amend fails by name with the same "not supported here" diagnostic every other checkout-dependent verb already emits. amend keeps working normally in a checkout; it just no longer accepts -R at all. closed check-in: 4b5dca852c user: dmestas tags: iss12

20:43
Merge iss4: zit stat, the store's own byte accounting and artifact roll

The store could not be interrogated from outside a checkout: no way to ask how many artifacts it holds, which ones, or how many of its bytes are content rather than framing. zit stat answers from one pass over the frames -- store-bytes, content-bytes, frame overhead, artifact and delta counts. Content is summed from each frame's payload during the walk, never derived from the .zit file size, which also carries the store header, a per-frame header and padding; a size-derived figure would look like a result and be a fabrication, so the two stay separate fields and the overhead between them stays checkable. --artifacts lists the stored hashes for comparison across stores. The frame walk and its accounting live in storestat, shared with the storage benchmark, so the verb inherits the benchmark's exact-accounting test and the two cannot drift. check-in: 921678c019 user: dmestas tags: trunk

20:37
Merge iss8: pull converges past the server's unclustered window

A fossil server folds artifacts older than its ~100-entry unclustered window into clusters and stops announcing those members with igot -- the M cards are the announcement. The client stored a received cluster as ordinary content and read nothing from it, so a pull past that window fetched only the cluster and then terminated, silently, having gained nothing. Cluster membership now folds into the phantom set, so the next round gimmes the members. A pull that reaches a clean terminal still lacking artifacts the peer announced now exits nonzero, rather than reporting a success that leaves a repository quietly out of date. check-in: 8d3ebc0c9d user: dmestas tags: trunk

19:18
stat: the content-bytes e2e gets an oracle it shares no code with

The honesty test asserted that content-bytes below store-bytes and that the byte fields sum to the file. Both hold for anything of the form `size - k`: one because a subtraction is smaller, the other because it is that subtraction's identity. The test named the defect it existed to catch and could not see it.

`statOracle` now totals frames from the store's per-rid index -- `payloadLenOf` for content, `frameSpanOf` less payload for overhead -- and the test pins the printed fields to it. It shares nothing with `storestat.walk`, so `size - header_len` for content now fails, as does overhead taken across all frames. One fabrication stays invisible and the test says so: subtracting the walk's own overhead and config from the size IS the measurement.

`stat_help` sat between `status_help` and its doc comment, so that comment documented the wrong constant and claimed an e2e transcription `stat` lacked. Restored, documented, and written.

A repeated `--type` was silent last-wins; a usage error now. runStat claimed "one pass" over two traversals; the claim goes, not the open, which verifies what the walk trusts. closed check-in: 0e1f7a719a user: dmestas tags: iss4

19:15
Add -R/--repo and -n N to the read verbs

-R/--repo PATH names the repository to read instead of the one found by walking up from the current directory. It is lifted out of argv before verb dispatch, so one implementation serves every read verb and each verb's grammar is unchanged. Resolution requires only the .zit store, not the .zit-checkout discovery also demands: reading a repository with no working tree is the point of the option. PATH may name the directory holding .zit or the .zit file itself. Verbs that read or write a working tree refuse -R by name; accepting it and silently operating on the current checkout is the failure worth a diagnostic to prevent. The spelling matches fossil.

-n N (alias --limit N) shows the N most recent entries on timeline and log; 0 means all, as in fossil. This deliberately overloads -n, which remains --dry-run on add and rm, just as fossil overloads it between timeline and clean. A non-numeric or negative value is refused.

An expectation asserting log rejected -n is inverted: it recorded the absence of the limit, not a rule about the grammar.

timeline still defaults to the whole listing, not fossil's 20 entries. check-in: b25e01be95 user: dmestas tags: iss12

19:14
sync: absorb cluster membership so pull converges past the unclustered window

A fossil server folds artifacts past its ~100-entry unclustered window into cluster artifacts and stops announcing them with igot: the M cards become the announcement. The client stored a received cluster as ordinary content and read nothing from it, so no phantom appeared, the 3.7(a) continuation saw an empty table, and the exchange terminated after one round having fetched only the cluster. Repository size, not delta size, was the trigger; clone converges on the 8.2 RID cursor, not on gimme, so it was unaffected.

Client.absorbCluster folds the members into the phantom set with the disposition an igot for each would produce, so the next round gimmes them. Membership is validated before any is applied; a clone skips it for the reason it announces no igot. On the cfile path the container inflates into a scratch buffer, not through store.content, which would memoise plaintext.

Client.unresolvedPhantoms counts announced-but-unobtained artifacts; pull exits nonzero when a run reaches a clean terminal with any outstanding, so a pull that stops short of what the peer holds is no longer reported as success. closed check-in: 5535de0f45 user: dmestas tags: iss8

19:07
open: add --manifest[=r,u,t] for the working-tree manifest files

Repositories that enable fossil's `manifest` setting — fossil, althttpd, pikchr — expect `manifest`, `manifest.uuid` and `manifest.tags` in the checkout; their builds read them to stamp a version. A zit checkout could not produce them, so such a build failed or, worse, succeeded with no version embedded.

`zit open --manifest` writes them; the bare flag means `ru`, a value picks any combination of `r` (the check-in artifact), `u` (its hash) and `t` (its branch and symbolic tags). The choice is recorded in the checkout record, so every later `update` refreshes the files. Absent flag writes nothing.

The selection is explicit rather than read from the cloned repository because the two places fossil keeps it disagree and only one syncs: althttpd's versioned setting says `on`, its local config `rtu`. A checkout reading the setting could diverge from a fossil checkout carrying a local override while appearing to promise fidelity.

Contents match fossil byte for byte, including the sterilizing comment it appends to `manifest`. A tracked file of one of those names is never replaced. closed check-in: dc36496c3b user: dmestas tags: iss6

18:46
Add `zit stat`: the store's own byte accounting and artifact roll

The store could not be interrogated about its contents: no way to ask how many artifacts it holds, which ones, or how many of its bytes are artifact content rather than framing. `zit stat` answers that from the frames themselves, emitting `field value` lines: store-bytes, header-bytes, content-bytes, frame-overhead-bytes, config-bytes, artifacts, full-form, deltas. The byte fields account for the file exactly.

content-bytes and store-bytes stay separate on purpose. Content is measured by summing each artifact frame's payload length; it is never derived from the `.zit` size, which also carries the store header, a header per frame, and alignment padding. A size-derived figure would look like a result and be a fabrication, so the overhead separating the two is reported alongside and stays checkable.

`--artifacts` lists the stored hashes one per line in ascending order, so two stores' rolls compare with `comm`; `--type=full|delta` narrows by storage form, read from the index the open already built.

The frame walk and its accounting live in `storestat`, shared with the storage benchmark, so the two cannot drift. check-in: 7c2262c837 user: dmestas tags: iss4

17:32
Merge blockers: a failed mv changes nothing, and its recovery is exact (B4)

mv recorded a rename in the managed set and persisted it before attempting the filesystem move, with no rollback. A move into a nonexistent directory left the set naming a path that held no file: status invented a missing entry, commit refused over it, and the recovery mv --help documents did not work.

mv now validates that the destination's parent exists and refuses before touching anything, so the failing case records nothing.

Two further defects on the same path. An inverse rename replaced the pending record instead of dropping it, so 'mv a b' followed by 'mv b a' left a b-to-a rename queued; the next commit carrying a real change would then emit an old-name F card asserting the file was formerly named b, a name never committed. That is false provenance in a permanent artifact, produced by the exact sequence the help text advertises as recovery. mvRecord now nets an inverse rename back out.

The error path printed raw Zig error names — a user saw 'AccessDenied' where clean and init already print 'permission denied' through reasonOf. It now uses the same mapping.

A residual window remains: a crash between the move and the record write is not atomic, documented at the site. Full working-tree crash consistency is a separate concern. check-in: 33a2ceb6d1 user: dmestas tags: trunk

17:27
mv: a reversal cancels the rename record instead of replacing it

`mv --help` advertises `zit mv NEW OLD` as the way to undo a rename, so it is the sequence a user is most likely to run. It left the pending record replaced rather than cleared: OLD->NEW->OLD queued a §4.7.6 old-name F card asserting OLD was formerly NEW -- a name no manifest has ever carried. The next commit with any real change would write that false claim about the past into a permanent artifact, where it cannot be taken back. A rename and its inverse are a no-op, so the pair now nets the record out.

This survived the guard because the guard's oracle was indirect: commit reports "nothing to commit" on CONTENT alone, so a stranded record passes it silently and surfaces only at the next commit that carries a change. The record is now read back directly.

mv's move errors render through `reasonOf` rather than `@errorName`, so EACCES reads "permission denied" instead of "AccessDenied". This was deferred as errno-sweep work, but the mapping already exists and `init` and `clean` print through it -- reuse, not new scope. A raw Zig error name is an internal identifier, not a diagnostic. closed check-in: 7235a86bcd user: dmestas tags: blockers

16:38
mv: move the working file before recording; failed mv records nothing

mv recorded the rename durably before attempting the filesystem move and rolled nothing back when it failed, leaving the set naming a path that does not exist and commit ready to record a rename that never happened. The reversal mv --help promises -- "run zit mv NEW OLD" -- failed too: it saw the old path still occupied and refused the collision.

mvCommit now renames first, then records; if the record fails the file is renamed back and nothing is persisted. Move-first beats record-first because undoing a durable write needs a second durable write, which can itself fail, nesting a failure inside the failure handler. Renaming a file back cannot realistically fail -- the source directory and name are still free. Build the guarantee on the rollback that cannot fail.

mvCheckLegal gains a dest-parent-exists check: stat of a path under a missing directory fails, so the old collision check read "absent" and waved it through. Residual window: a crash between move and record leaves the file at NEW with the set naming OLD, recoverable with add NEW + rm OLD.

ENOENT and EACCES were both rendered "input/output error". check-in: d7dd9ea30b user: dmestas tags: blockers

16:36
Merge blockers: `zit tag add NAME CHECKIN` mints a SINGLETON sym card (B6)

Lands the B6 increment (d8e5e811) on trunk. The fix itself is unchanged from that commit and its full reasoning is recorded there; this message covers the landing, because the merge was not a formality.

WHAT B6 FIXES

`tag` was read-only. `tag list` was the only subcommand, so a repository could not be given a name for a check-in at all. Unlike the other five blockers this was not a wrong answer — the verb told the truth about what existed — but the capability was simply absent. `zit tag add NAME CHECKIN` now mints one CONTROL artifact carrying a single `T +sym-NAME` card, targeting the resolved check-in's 64-hex hash.

THE SIGN IS THE WHOLE CORRECTNESS ARGUMENT

The card is a SINGLETON (`+`), not a propagating (`*`) tag. `resolveName` (src/repostate.zig:891) picks the max-`event.mtime` check-in among ALL active `sym-` rows, so a propagating `*sym-v1` would be inherited by every descendant and then resolve to the NEWEST of them — the name would silently drift off the check-in the user tagged, which is the same class of wrong answer as B1. `applyTagOps` (src/repostate.zig:727) gates propagation on `op.type != .singleton`, so a `+` card pins the name with ZERO repostate changes. The contrast with a branch is deliberate: a *branch* sym propagates precisely because descendants must inherit it; a *tag* sym does not, precisely because they must not.

CONFLICT RESOLUTION, STATED

One conflict, src/e2e.zig, positional rather than semantic: trunk's B5 block and B6's block are both appended at the end of the file over an empty common ancestor. Fossil again emitted the five-part conflict rather than the usual four — a "####### SUGGESTED CONFLICT RESOLUTION follows" section carrying its own proposed merge sits between the local copy and the ancestor section. Splitting only on the four familiar markers swallows that separator into the kept text, which then compiles as garbage; B3's landing recorded the same trap and it recurred here verbatim.

The two candidate sections were compared before choosing: the SUGGESTED section and the MERGED IN section are byte-identical (257 lines each), and the common ancestor is a single empty line. The resolution keeps trunk's 8 B5 tests plus exactly one copy of B6's 5-test block. Verified post-resolution: no residual marker of any of the five kinds, 432 tests in the file, no duplicated test name, and the B3 (5), B5 (8) and B6 (7) blocks all present at their expected counts. One further edit was needed — the splice left a doubled blank line at the seam, which `zig fmt` flagged; the reformat is that one blank line and nothing else.

TEST EVIDENCE ON THE MERGED TREE

Re-verified on trunk after the merge, not inherited from the branch: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e` (432/432, no filtering), `zig build dst` (6 seeds x 1000 steps) and `zig build smoke` (S1-S6) each exit 0. `fossil extras --dotfiles` is empty.

The blocker repro was also driven by hand against the rebuilt merged binary rather than inferred from the source: `zit tag add v1 tip` exits 0 and prints the artifact hash; after a second commit `zit cat a.txt v1` is the tagged content while `zit cat a.txt tip` is the newer one, and `zit tag list tip` does NOT carry v1 — the singleton pins and is not inherited. `zit tag add v1` alone is a usage error at exit 2, and `zit tag add v2 nosuchcheckin` exits 1 naming the check-in and leaves no tag behind.

CHANGED EXPECTATIONS (all three encoded the missing capability, nothing else)

cli tag B-tag-7 (src/cli.zig) and cli tag B-tag-13 (src/e2e.zig) used the literal token `add` as their stand-in for "not a subcommand". `add` IS one now, so both use `cancel`, a word that still is not — cancel/remove semantics stay deferred to 0.2. cli tag B-tag-11's verbatim `tag_help_expected` gains the `add` usage line and its paragraph, because `tag --help` must teach the new grammar. The diff was audited for other expectation changes and there are none.

RECORDED, NOT ASSUMED: branch creation shares no read-only gap. It is already served by `commit --branch`, which mints the propagating branch/sym pair directly on the check-in manifest (src/cli.zig `composeCommitTags`), so there is no `branch add` to write.

No borrowed technique: the singleton-sign argument and the `mintControlArtifact` extraction are original to this change. No performance claim is made. check-in: a5b6db7a8c user: dmestas tags: trunk

15:31
Merge blockers: refuse symlinks consistently across add/addremove/commit (B3)

Lands the B3 increment (1aeb531cac) on trunk. The fix itself is unchanged from that commit and its reasoning is recorded there; this message covers the landing, because the merge was not a formality.

WHAT B3 FIXES

A symlink anywhere in the checkout permanently wedged `commit`. `extras.zig` classified a symlink as an extra, and the recursive `add DIR` / `addremove` paths reused that sweep as their add set without re-applying the kind test that explicit `zit add sym.txt` and `commit` both apply. The recursive path tracked exactly what the direct path refused, so after `zit add .` every subsequent `zit commit` died with "cannot commit 'sym.txt': not a regular file" and the only way out (`zit rm sym.txt`) was named nowhere. Consistent refusal is the contract: 0.1.0 does not version symlinks on any path.

WHY IT LANDED SEPARATELY

B3 was committed to `blockers` off the old branch tip and never reached trunk; trunk meanwhile took B1, B5 and a CI change. The sibling B6 commit (d8e5e811) had already absorbed B3 into its own tree, so B3 would eventually have arrived on trunk bundled inside B6. Landing it on its own keeps each increment gated and merged on its own evidence rather than riding in on another's, and it shrinks the diff B6 still has to land.

CONFLICT RESOLUTION, STATED

One conflict, src/e2e.zig, positional rather than semantic: trunk's B5 block and B3's block are both appended at the end of the file, over an empty common ancestor. Both blocks are kept, one copy each.

Worth recording because it is a trap: fossil emitted a five-part conflict, not the usual four. Between the local copy and the ancestor section it inserted a "####### SUGGESTED CONFLICT RESOLUTION follows" section carrying its own proposed merge. Splitting only on the four familiar markers silently swallows that separator line into the kept text, which then compiles as garbage — it surfaced here as `zig fmt` and the compiler both failing at that exact line, not as anything that looked like a merge problem. The resolution keeps the local copy plus fossil's suggested section (B5 then B3) and drops the duplicate merged-in copy, which was verified to be a strict substring of what was kept. Post-resolution the file holds 13 tests in that region with no duplicated test name and no residual marker of any of the five kinds.

TEST EVIDENCE ON THE MERGED TREE

Re-verified on trunk after absorbing the latest sibling (861b223e3c), not inherited from the branch: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e`, `zig build dst` (6 seeds x 1000 steps) and `zig build smoke` (S1-S6) each exit 0. Full battery, no filtering. `fossil extras --dotfiles` is empty.

The B3 repro was also driven by hand against the rebuilt binary: `zit add .` skips sym.txt and says so on stderr, `zit commit` succeeds, `zit status` reports nothing, `zit extras` lists sym.txt, explicit `zit add sym.txt` refuses with the symlink wording at exit 1, and `addremove` withholds it silently.

No borrowed technique. No performance claim is made. check-in: 01dc9f8e8a user: dmestas tags: trunk

15:25
CI: fuzz sweep at 100 rounds per target, not 2000

The sweep could not finish inside its own 10-minute step timeout. Per-target cost varies by roughly 276x across the corpus: artifact_parse_garbage runs 100 rounds in 0.18s, while logstore_pack_same_path_round_trip takes 49s because it performs a full store pack every round. At 2000 rounds that single target needs about 16 minutes on its own.

The original figure was extrapolated from one cheap target on the assumption that per-round cost was roughly uniform. It is not, and the assumption was never measured. 100 rounds is measured: the full 27-target sweep takes about 76s.

This makes CI fuzzing a regression check — every target still runs clean on every push — rather than a bug hunt. Finding new defects needs depth, and depth belongs in an out-of-band run with its own budget, where a long sweep costs nobody a merge. check-in: 861b223e3c user: dmestas tags: trunk

15:16
tag: add `zit tag add NAME CHECKIN`, minting a SINGLETON sym card

`tag` was read-only: `tag list` was the only subcommand, so a repository could not be given a name for a check-in at all (blocker B6). The verb told the truth about what existed, but the capability was simply absent.

WHAT CHANGED

`zit tag add NAME CHECKIN` mints one CONTROL artifact carrying a single `T +sym-NAME` card targeting the resolved check-in's 64-hex hash.

THE SIGN IS THE WHOLE CORRECTNESS ARGUMENT. The card is a SINGLETON (`+`), not a propagating (`*`) tag. `repostate.resolveName` (src/repostate.zig:891) picks the max-`event.mtime` check-in among ALL active `sym-` rows, so a propagating `*sym-v1` would be inherited by every descendant and then resolve to the NEWEST of them — the name would silently drift off the check-in the user tagged, which is the same class of wrong answer as B1. `applyTagOps` (src/repostate.zig:727) gates propagation on `op.type != .singleton`, so a `+` card pins the name with ZERO repostate changes. The contrast with a branch is deliberate and now documented at both sites: a *branch* sym propagates precisely because descendants must inherit it; a *tag* sym does not, precisely because they must not.

`amend`'s steps 5-6 — assemble the control manifest, run the runtime-safety round-trip self-check, `putArtifact` + `commit` under the single fsync — are extracted into `mintControlArtifact`, and `tag add` is its second consumer. Both verbs resolve a check-in, compose a T run, then mint; that last step is now written once. amend's existing battery guards the extraction unchanged. The helper's error set is `logstore.OpenError`, the widest of the three the body can produce, so each verb renders it under its own name.

`tag add` closes its read view BEFORE the read-write append (commit's durability pattern), so it owns its repository lifetime rather than sharing the listing path's `defer repo.deinit`. Resolution and the empty-NAME check both precede the mint: a run that cannot resolve its CHECKIN mints nothing, asserted by a test.

`selectInheritedSyms`'s comment said zit mints no singleton syms itself, so the §6.2 exclusion of them from a branch move's cancel set was reachable only via a synced `T +sym-X *`. That is no longer true — the comment is corrected. The behavior needed no change, and its unit test already covered a singleton sym.

RECORDED, NOT ASSUMED: branch creation shares no read-only gap. It is already served by `commit --branch`, which mints the propagating branch/sym pair directly on the check-in manifest (src/cli.zig `composeCommitTags`), so there is no `branch add` to write. Cancel/remove semantics stay deferred to 0.2 — closing "cannot create tags" is the blocker; a full tag lifecycle is not.

RED PROOF (per BLOCKER-FIX-PLAN.md section 2, no product code changed until RED was proven; markers `// REGRESSION B6:`)

End-to-end, src/e2e.zig — all five failed pre-fix at `tag add` with exit 2 ("unknown subcommand 'add'"):

cli REGRESSION B6: tag add creates a tag that tag list and tag list CHECKIN report cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (tag before commit) cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (commit before tag) cli REGRESSION B6: a created tag survives pack cli REGRESSION B6: a created tag survives a clone

The pinning pair runs both orderings — tag-then-commit exercises inheritance at commit time, commit-then-tag exercises the propagation walk over already crosslinked descendants. Identical behavior in both is what proves the mechanism rather than luck. pack and clone prove the round trip: a synced artifact that does not survive sync is a worse lie than a missing verb.

Added alongside them: `tag add`'s arity errors, and an unresolvable CHECKIN leaving no tag behind. Unit, src/cli.zig: the parser's add grammar (both positionals required, `--help`/bad option winning over arity, `--` handling), `composeTagAddCards` minting exactly one `+sym-` card at the target, its control round trip through `artifact.parse` proving the sign survives the wire, and leak-freedom under `checkAllAllocationFailures`.

TEST EVIDENCE NOW

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, `zig build test`, and `zig build e2e` each exit 0 — full battery, no filtering.

CHANGED EXPECTATIONS (both encoded the missing capability, nothing else)

cli tag B-tag-7 (src/cli.zig) and cli tag B-tag-13 (src/e2e.zig) used the literal token `add` as their stand-in for "not a subcommand". `add` IS one now, so both use `cancel`, a word that still is not.
cli tag B-tag-11's verbatim `tag_help_expected` gains the `add` usage line and its paragraph, because `tag --help` must teach the new grammar.

No borrowed technique: the singleton-sign argument and the mint extraction are original to this change. No performance claim is made.

SIBLING MERGE

This checkout absorbed the sibling B3 commit (1aeb531cac) before landing. The only conflict was in src/e2e.zig and was positional, not semantic: both changes append a new regression block at the end of the file. Both blocks are kept, one copy each; the two candidate B3 hunks fossil presented were byte-identical, and the full battery above was re-run on the merged tree, not just on mine. check-in: d8e5e8117d user: dmestas tags: blockers

15:13
Merge blockers: an add-only merge registers its file and commits (B5)

A merge whose only effect was an ADDED file was silently lost: the file landed on disk but never in .zit-managed, so status said "nothing to report", extras listed it as junk, and commit refused "nothing to commit (no changes)" while dropping the merge parent. The branch existed on one disk and undo was the only escape.

No new state was needed — CheckoutState.merge_parent already carried the pending merge and commit already consumed it as a two-token P card. The gap was the managed set and two refusals that did not know about it. applyMerge now derives the membership mutation from the same entry stream that drives the filesystem effects, which also fixes the symmetric bug for free (a merge-deleted file used to stay managed and invent a phantom "missing") and covers cherrypick/backout, which share the seam. status names a pending merge/cherrypick/backout. commit's no-op refusal no longer fires over a pending merge, so a convergent-content merge can no longer wedge the tree.

One test expectation changed: the new add-idempotence case asserted the "already tracked" notice on stdout; it has always been a stderr diagnostic (three shipped e2e cases assert it there). The test had the stream wrong, not the product.

Merged tree re-verified on trunk rather than inherited from the branch: zig fmt --check ., zig build, zig build test, full zig build e2e (420 tests, all eight REGRESSION B5 cases green including the contract-partner undo guard) and smoke S1-S6 all exit 0. fossil extras --dotfiles is empty. The blocker repro was also driven by hand against the rebuilt binary end to end.

Landed alongside, not merged here: another session committed B3 (1aeb531cac) to blockers off the OLD blockers tip, leaving that branch with two leaves. This increment branched off trunk, so the two do not interact and B3 remains that session's to land. check-in: 99f4895c04 user: dmestas tags: trunk

15:09
B5: an add-only merge registers its file, shows as pending, and commits

A merge whose only effect was an ADDED file was silently lost. `merge` wrote the incoming file to the working tree but never inserted it into `.zit-managed`, so the file was simultaneously on disk and untracked: `status` said "nothing to report", `extras` listed it as junk, and `commit` refused with "nothing to commit (no changes)" — dropping the merge parent that `merge` had recorded in `.zit-checkout`. The branch existed on exactly one disk and `undo` was the only escape.

No new state was needed. CheckoutState.merge_parent already carries the pending merge (src/checkout.zig), runMerge already records it, and commit already consumes it as a two-token P card and clears it. The gap was entirely in the managed set and in two refusals that did not know about it.

applyMerge now DERIVES the membership mutation from the same entry stream that drives the filesystem effects, rather than accepting a parallel list from the caller: a `.write` registers its path (insert is idempotent, so an ordinary edit-merge is unaffected) and a `.delete` deregisters it. One stream, one truth. Deriving it there rather than in computeAndApplyMerge also fixes the symmetric adjacent bug for free — a merge-deleted file used to stay managed and invent a phantom "missing" that commit then refused over — and covers cherrypick and backout, which share the same seam. The pre-merge set joins the in-process rollback images so a mid-apply failure restores membership along with the files and the checkout; the undo journal already snapshotted `.zit-managed`, so abort and undo deregistration fall out unchanged.

The set is read twice rather than rebuilt from `old.iterate()`: the on-disk record carries a pending `mv` rename beyond the bare membership, and rebuilding would have silently dropped it.

status now names a pending merge/cherrypick/backout in its header block. A merge whose file effects are already materialized was otherwise indistinguishable from a clean tree, with no way to see that a second parent was queued.

commit's no-op refusal no longer fires while a merge or special op is pending. A convergent-content merge — both sides making the same edit — composes to F cards identical to the parent's, so `unchanged` went true and the pending merge wedged with no commit path at all. The P/Q card is itself the change.

Test-expectation change: the new "zit add of a merge-added file is idempotent" case asserted the "already tracked" notice on stdout. That notice has always been a stderr diagnostic (three shipped e2e cases assert it there); the test had the stream wrong, not the product. The assertion's intent — add sees the merge-registered file as already tracked and does not re-add it — is unchanged.

No technique was borrowed here: the mechanism rides zit's existing applyMerge, ManagedSet and journal seams. Fossil's vmerge/vfile registration was consulted as an analog for the SHAPE of the problem only, and none of its code or algorithm is reproduced.

Verified in this worktree: fmt, build, zig build test, full zig build e2e (420 tests, all eight REGRESSION B5 cases green, contract-partner undo guard still green), and smoke S1-S6. The blocker repro was also driven by hand against the rebuilt binary: merge reports 1 file changed, status shows "pending merge with <hash>" and "added feat.txt", extras is empty, commit lands, and info tip shows both parents. closed check-in: de05152541 user: dmestas tags: blockers

15:07
add/addremove/commit: refuse symlinks consistently (blocker B3)

A symlink anywhere in the checkout permanently wedged `commit`. `extras.zig` classifies a symlink as an extra (B-extras-9), and the recursive `add DIR` / `addremove` paths reuse that sweep as their add set without re-applying the kind test that explicit `zit add sym.txt` and `commit` both apply. The recursive path therefore TRACKED exactly what the direct path REFUSED: after `zit add .`, every subsequent `zit commit` died with "cannot commit 'sym.txt': not a regular file", `zit status` called an on-disk file missing, and the only way out (`zit rm sym.txt`) was named nowhere. `add .` is the documented whole-tree workflow, so any repo containing a symlink was stuck.

WHAT CHANGED

Consistent refusal: 0.1.0 does not version symlinks, on every path.

1. `extras.Extras` entries widen from a bare path to `Entry{path, is_symlink}`. The sweep already lstat-classified every entry (`resolveKind`); the verdict now rides out on the entry instead of being re-derived by callers. That is the point of the shape: `add` and `addremove` do NOT re-stat swept items, so the two paths cannot drift from the deep module's classification again — which is the mechanism of this bug. Ordering is unchanged (sort is by `path`; `is_symlink` never participates), and every postcondition assert is preserved.

2. The five `items()` consumers split by role. `extras` and `clean` ignore the tag — enumerating verbs do not judge, and a symlink stays a listable, deletable extra. `add`'s directory sweep and `addremove`'s insert loop filter on it.

3. `classifyPath`'s catch-all `else => .not_regular` splits: `.sym_link` gets its own `Outcome` and the wording `symlink (not versioned in 0.1.0)`; a fifo/socket/device keeps "not a regular file". Those are not the same refusal and must not read as one. The wording is one shared const so the explicit refusal and the recursive skip notice cannot diverge.

4. `commit`'s `Capture.not_regular` carries whether the entry was a symlink, and `commitNotRegular` renders the recovery for that case: "run 'zit rm PATH' to stop tracking it". This is how repos already wedged by a pre-fix `add .` get out; there is no migration code and none is needed.

REPORTING DECISION (stated, not implied)

`add` prints each skip to stderr, alongside its existing already-tracked notice: add's contract is per-argument feedback. `addremove` withholds symlinks SILENTLY. Its report is a two-channel ADDED/REMOVED ledger of state transitions, and a skipped symlink is a non-transition; growing a third notice channel there would change the shape of a report other tooling reads. `zit extras` is the verb that shows the symlink is still present and untracked.

`status` needed no change: post-fix the symlink is simply untracked.

TESTS

Five e2e regressions, marker `// REGRESSION B3:`: the breakdown doc's repro chain verbatim (init/add ./commit/status/extras); an `addremove` variant with symlinks sorting BOTH before and after the regular file the sweep also yields, so a filter that latched off the previous item's kind fails one of the two; explicit `add sym.txt` refusing with the symlink wording and tracking nothing; the wedge-recovery chain (pre-tracked symlink -> commit names `zit rm` -> rm -> commit succeeds -> status clean); and a `clean --dry-run` contract-partner guard that was green before the fix and stays green through it, proving clean still lists a symlink as an extra after the `Extras` type change. A unit assertion in `extras.zig` pins the tag itself, and `commitNotRegular`'s renderer test covers both branches.

Two pre-existing e2e expectations changed, both because they encoded the bug or its wording:

- `cli addremove B-addremove-2` asserted the ADDED set was byte-identical to `zit extras`, symlink included. That identity IS the defect. It now asserts the add set is the extras set MINUS symlinks, and additionally that the symlink remains an extra. - `cli add B-add-5` asserted the fifo-generic wording for a named symlink. It now asserts the symlink-specific wording.

Four `commit` invocations inside the new B3 tests use `runCommitEnv` with an explicit user rather than bare `runZit`; the isolated e2e environment has no resolvable author, which is a harness requirement, not a behavior change.

TEST EVIDENCE

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e` (417 tests) and `zig build dst` each exit 0 — full battery, no filtering. Pre-fix the four B3 repro tests failed and the `clean --dry-run` guard passed, as designed.

No borrowed technique: the entry-tag shape is original to this change. No performance claim is made. check-in: 1aeb531cac user: dmestas tags: blockers

14:32
Merge blockers: resolve same-mtime ties by DAG ancestry (B1/B2)

Three commits inside one second made 'zit cat f.txt tip' return the root's content and 'trunk' the middle's, while 'zit leaves' named the newest — the binary contradicting itself, exit 0, no warning.

The diagnosis that opened the campaign was wrong and is worth recording: it blamed a single lexicographic-hash tiebreak in mergeBaseOf. Building the equal-mtime chain in BOTH hash orderings produced identical failures, which ruled hash order out as the mechanism. There were three independent tie sites — tipForm settled on events insertion order, resolveName on tagxref iteration order, mergeBaseOf on artifact hash. A fix confined to the originally-blamed line would have left tip and trunk still wrong.

Equal event.mtime is now settled by ancestry: a descendant beats its ancestor. Genuinely concurrent nodes keep a deterministic least-hash fallback — hash order between unrelated leaves was never the defect. Timeline gets a second pass re-ordering equal-mtime runs descendants-first.

Ride-alongs on the same surface: 'update' accepts symbolic names (SF1), and a 'tip' naming a non-leaf is now an in-binary assertion (SF5) — the tripwire that would have caught this originally.

Merged tree re-verified on trunk: fmt, build, test, e2e, dst 6x1000 all green. Remote CI is unavailable (self-hosted runner offline); the local battery is the gate per operator direction. check-in: bc7b914316 user: dmestas tags: trunk

11:56
repostate: break same-mtime resolution ties by DAG ancestry, not hash order

Three commits made inside the same second made `zit` return the WRONG file content for `tip` and `trunk`, and made the binary contradict itself: `zit info tip` reported "leaf: no" while `zit leaves` named the same check-in a leaf. `zit cat f.txt tip` returned the ROOT's content and `zit cat f.txt trunk` returned the MIDDLE's, on a three-commit chain whose newest content was `v3`. A version-control system that silently hands back an old revision under a name that means "newest" is disqualifying, so this lands before anything else.

WHAT WAS WRONG — three independent tie sites, not one

The originally-recorded diagnosis blamed a single lexicographic artifact-hash tiebreak in `mergeBaseOf`. That was wrong for the reported symptom: the RED-proof gate built the equal-mtime chain in BOTH hash orderings and observed IDENTICAL failures, which rules out hash order as the mechanism. Fixing only that line would have left `cat tip` and `cat trunk` still returning wrong answers. The real picture:

- `tipForm` — `mtime > best_mtime`, a strict `>` with no tiebreak at all, so the first entry in `events` INSERTION order won. Symptom: `tip` -> ROOT. - `resolveName` — ties settled by `tagxref` hash-map ITERATION order. Symptom: `trunk` -> MID. - `mergeBaseOf` — lexicographic artifact hash. A genuine defect, proven RED at unit level, but not the cause of the tip/trunk symptom and unreachable from the e2e merge path.

WHAT CHANGED

Equal-`event.mtime` ties are now settled by DAG ancestry: a descendant beats its ancestor. Genuinely concurrent nodes — same mtime, no ancestry relation — keep a deterministic least-artifact-hash fallback, documented at each site; hash order between UNRELATED leaves was never the bug. The shared helper (`MtimeMaxPick`) is used by `tipForm`, `resolveName`, `openLeafOf` and `dateResolve`, so every symbolic-name surface settles ties one way.

Timeline ordering gets a second pass that re-orders each equal-mtime RUN descendants-first, deliberately overriding the hash tiebreak. It is a per-run repeated-maximal-element extraction, NOT a sort predicate: "descends-from, else hash" is not a strict weak order, and handing it to `std.mem.sort` would be undefined behavior. Runs are bounded by the check-ins sharing one millisecond.

Settling a tie by ancestry allocates, so `resolveName`/`resolve`/`openLeafOf` gained `OutOfMemory`. Every caller switches it as an ABORT, never as a mismatch or "already correct" verdict — reporting an allocation failure as the exit-0 up-to-date message is exactly the lie those switches exist to prevent. `repository_test`'s closed-error-set assertion moved 2 -> 3 for that reason: it encoded the pre-fix set, not an inconvenient expectation.

Two ride-alongs on the same resolution surface:

- SF1 — `zit update trunk` used to fail as "invalid check-in ID": update accepted ONLY canonical 40/64-hex. Its argument is a check-in name like every other verb's, so it now runs the shared 8.3 procedure. A canonical ID still short-circuits, leaving update's cold path byte-for-byte as it was. - SF5 — a `tip` that names a non-leaf is now an in-binary assertion, not just a test. It is legitimate ONLY under a non-monotone clock (a child stamped strictly earlier than its parent); a child that TIES its parent means the ancestry tiebreak failed to fire, which is the defect itself. This tripwire would have caught the original bug.

RED PROOF (recorded by the RED-proof gate, 2026-07-19; per BLOCKER-FIX-PLAN.md section 2, no product code changed until RED was proven)

Unit, `src/repostate_test.zig`, deterministic equal-mtime chains built directly in BOTH hash orderings — no wall-clock dependence:

repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: tip and resolveName agree with the leaf set in both hash orderings repostate REGRESSION B1: mergeBaseOf picks the deepest common ancestor when every candidate shares an mtime [failed pre-fix: expected 2, found 1]

End-to-end, `src/e2e.zig`, same-second commits with NO sleep between them — same-second IS the point:

cli REGRESSION B1: same-second commits resolve tip and trunk to the newest check-in cli REGRESSION B1 (SF1): update accepts a symbolic name and lands on the newest check-in cli REGRESSION B1: open materializes the newest same-second check-in into a fresh clone cli REGRESSION B1: a same-second two-branch merge picks the correct base and is never up to date

All of the above failed against pre-fix trunk; the two hash-ordering pairs failed IDENTICALLY, which is the evidence that retired the hash-order theory.

TEST EVIDENCE NOW

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, and `zig build test` each exit 0 — full battery, no filtering. The only pre-existing expectation changed is the `repository_test` error-set count noted above, and it changed because it encoded the old set.

Markers `// REGRESSION B1:` are stable so state probes can grep trunk for them.

No borrowed technique: the ancestry tiebreak and the per-run topological micro-ordering are original to this change. check-in: bdb49cc5ca user: dmestas tags: blockers