50 descendants and 50 ancestors of 602c2459f268b957
|
2026-08-01
| ||
| 19:06 |
comments: drop the unaccepted §6.4 erratum references
The auto-retune erratum was accepted and is in the draft body; these comments still said it was submitted and unapplied. Comments and one test name only. check-in: a2eb78b7be user: dmestas tags: sub-manifest | |
| 15:03 |
merge trunk: the issue 22 clone fix reaches the submanifest bench
Bench now builds ReleaseSafe, which is the mode the O(store) asserts fired in, so without this the clone arm hits them at width. check-in: c1f44ba0ed user: dmestas tags: sub-manifest | |
| 14:46 |
merge issue22-diagnostics: reclone got HTTP 500 on a healthy store
ReleaseSafe asserts made the clone answer O(store), and serve's 5s deadline raced read + answer + write as one unit -- so a slow answer was cancelled mid-flight and the dead pread surfaced as error.Storage. Asserts gone (zit verify covers them); the deadline now races read and write separately. Not scale-dependent -- machine speed vs a wall clock. Stalled peer still cut at 5.0s. test/gate/e2e/dst green. check-in: 94bebf57f1 user: dmestas tags: trunk | |
| 12:27 |
submanifest bench: report the real optimize mode, not a literal
The emitted `optimize` field was the string "ReleaseFast", hardcoded into the JSON format string rather than derived from anything. It reported ReleaseFast for every record the benchmark has ever written, whatever the executable was actually built as - so the one field whose job is to say which binary produced a measurement could not be wrong in a detectable way, only in a silent one. Now `@tagName(builtin.mode)`, alongside the os/arch tags beside it that were already derived that way. Found by switching bench to ReleaseSafe and watching `executable_sha256` change while `optimize` did not. check-in: be18ff3902 user: dmestas tags: sub-manifest | |
| 12:27 |
build: benchmarks measure the binary that ships, not ReleaseFast
`--release` resolves to ReleaseSafe here as a correctness decision: src/ carries over a thousand asserts that turn a detected inconsistency into a crash instead of a durable write, and ReleaseFast compiles every one of them away. The bench executables opted out of that on the grounds that "a benchmark must measure optimized code". Optimized, yes - but it has to be the code users run, or the number describes a binary nobody executes. This matters more than a uniform constant would suggest, because the cost is not uniform: the same rationale block records `zit pack` at 599 ms fast against 15.2 s safe on one store. A measurement taken in the mode that strips the asserts cannot be compared against, or used to make a claim about, the shipped artifact. `bench_zit` is the sharpest case - the serve benchmark spawns it as the real `zit` binary, so it stood in for production while being built unlike it. check-in: 30b7d5e766 user: dmestas tags: sub-manifest | |
| 12:20 |
serve: deadline bounds peer I/O, not our own answer
The 5s per-connection deadline raced read + answer + write as one unit, so a slow answer was indistinguishable from a stalled peer -- and cancelling mid-answer surfaced as error.Storage, telling the peer the repository was damaged. Issue #22. Read and write are raced separately, each with the full deadline; the answer runs unraced since it is bounded by the store, not the peer. Verified: a peer that sends one body byte and stalls is still cut at 5.0s; pikchr reclone passes. test 2327/2327, gate, e2e 671/671, dst 6x1000, fuzz serve_request_garbage. closed check-in: 73bdc2fef1 user: dmestas tags: issue22-diagnostics | |
| 12:06 |
sync: drop the O(store) asserts from the clone answer path
Under runtime_safety (ReleaseSafe -- the shipped build) emitCloneReply reconstructed every served artifact and its delta basis just to assert a declared size. That pushed a clone round past serve's 5s per-connection deadline, which cancels mid-answer; the in-flight pread then failed and the peer got HTTP 500 on a healthy store. Issue #22. zit verify checks the same properties on demand, which is where O(store) verification belongs. Also names the failure server-side: serve collapses every AnswerError into a bare 500, so serveAnswer now logs which one. Reclone verified green: wapp 1038, althttpd 1695, pikchr 2842, libfossil 17218 (18 rounds). test 2327/2327, gate, e2e 671/671, dst 6x1000. check-in: 879b867bb1 user: dmestas tags: issue22-diagnostics | |
| 08:31 |
dst storage sweep: the repair's own reopen is not workload under test
crashCheckOnce repairs by truncating below the earliest injected bitflip and reopening. When the truncation empties the log, that reopen writes a fresh header - and the injected short-write fault could land on it, so open returned Storage, the sweep mapped it to Io, and the run failed. Nothing there was a statement about the store: the simulator faulted its own recovery. The repair and its reopen now run against the healthy disk phase 2 heals to, the all-zero FaultCfg. Faults come back before the invariant check, which is workload again. Pre-existing: trunk fails seed 201 with the same Io. check-in: 9e3107e570 user: dmestas tags: sub-manifest | |
| 08:31 |
dst gen: check merge parents for cycles, not just the primary chain
The generator's own note says a real check-in DAG is acyclic by construction - a manifest's hash covers its P-card - while the phantom-parent flavor lets a hash be named before its record exists, so a later pick can close a loop and must be checked. The check only ever walked primary_parent_of, and the merge parent was drawn by pickInsertedIndex with the comment "no cycle risk". That holds for the primary chain alone. Section 3.3 ancestry, which the B1 tie-break walks, follows merge parents too: a merge edge into an already inserted check-in, paired with a phantom primary edge out of it, closes a loop the guard never looked at. ancestryMaximal then finds every candidate dominated and no maximal element, and panics - seed 192 built exactly that, 9 to 14 to 13 and back. parent_indices records every parent index a record names, wouldCloseCycle walks that graph visited-once, and the merge draw is checked like the primary one. A losing draw degrades to the single-parent record rather than drawing again, so the seeded stream shifts as little as it can. Pre-existing: trunk fails seed 192 identically, and gen.zig is byte-identical there. Only the default six seeds hid it. check-in: 992ff19a23 user: dmestas tags: sub-manifest | |
| 08:31 |
repostate: a check-in named twice in a tie must not eliminate itself
A name can reach the same check-in through two index entries, so one selection offers the same rid twice. MtimeMaxPick.offer keeps the first as `best` and appends the second to `tied`; winner then appends `best` back, so `tied` holds that rid twice. Ancestor sets are self-inclusive, so each copy found itself in the other's set. Dominance skipped only the same INDEX, so both copies were dominated, every candidate was eliminated, and the maximal element a finite DAG must have came up null - a panic on `best.?`, reached through resolveName. Dominance is about being a different check-in, not sitting at a different offset. The new test drives offer/winner directly, and also pins that a duplicate cannot shadow a real descendant. check-in: d3f58d2875 user: dmestas tags: sub-manifest | |
| 07:22 |
merge trunk: land the logstore slices, repair the sweep's rot oracle
Trunk's Slice 1/2 made both open paths header-only, so a rotted payload now opens clean and surfaces at content() instead of at open. The storage sweep still assumed open was the tripwire, so two of its own claims were wrong once the merge combined them with this branch's store: - crashCheckOnce repaired only "if the open itself reports corruption", a precondition Slice 2 removed. Injected rot reached the invariant check and read as data loss. NeedsRepair, private to the harness, now carries "refused AND genuinely damaged" to the repair that already existed. - rotWithinFrame spelled the per-frame overhead as 22, the header alone, so a frame's own trailing frame_crc32 counted as inert pad. A bitflip in those four bytes makes a reader recompute a correct checksum, compare it to a corrupted stored one, and refuse the frame; the sweep called that data loss. logstore now exports frame_fixed_overhead and the sweep derives it instead of respelling it. Both are trunk defects, not merge damage: trunk alone fails dst at seed 8. The merge only shifted the draws far enough to reach the first one at seed 3, inside the default gate's range. cli.zig's verb enum was the single textual conflict: a pure union of trunk's verify and this branch's submanifest and import-git. fmt, test, gate, dst green. check-in: 584314dcb7 user: dmestas tags: sub-manifest | |
|
2026-07-31
| ||
| 21:51 |
sync: name the HTTP status the peer answered with
"the peer answered with an unexpected HTTP status" sends an investigation nowhere. 500 (the server could not build a reply), 413 (the request exceeded the body ceiling), 400 (a malformed container) and 417 (a rejected Expect: 100-continue) are four different bugs, and the message distinguished none of them. Issue #22 has been open on exactly that sentence. transport.zig discarded the code at the status check because Zig errors carry no payload and the closed error set is deliberate. Rather than widen TransportError with a variant per status, the status is left on the Transport as last_status for the caller to read after the error, and syncRunFail names it: "the peer answered HTTP 500 (Internal Server Error)". Immediately useful: re-running the issue #22 gate against the fossil corpus with this build turns its failure_reason from the old sentence into HTTP 500, which -- together with the reply-size arithmetic -- narrows the cause to OutOfMemory or ServerStorage and eliminates every 4xx path. The issue's own hypothesis, a per-round shun-snapshot readiness problem, is not among the candidates: that arm is documented unreachable and the code agrees. Also corrects three comments Slice 2 falsified, all on the same read path: logstore's open no longer CRC-verifies payloads, so the serve snapshot's contentImpl/wireImpl read faults are no longer "post-scan EIO or bitrot after a verified scan" -- ordinary stored corruption now reaches serve there, on the read. That is a better shape (one bad frame fails the round that touches it rather than making the server refuse to serve at all) but it makes that arm live traffic, and anything reasoning about how often it fires must start from that. runPack and runShun both claimed their read-only open was an "integrity pre-flight" that aborted a corrupt source before a byte was written. It no longer verifies payloads. The guarantee still holds -- packLog pulls every artifact through srcContent, which propagates error.Corrupt rather than skipping the frame, and readFrameAt CRC-checks every read, so a rotted payload still aborts with the temp deleted and .zit untouched -- but it is now collected during the copy loop instead of at open. Both verbs RENAME OVER the original, so runPack's comment now states the consequence plainly: if srcContent is ever changed to answer null on a corrupt read, the rewrite would silently omit the damaged artifact and then delete the only copy of it. Verified: fmt clean, test 2327/2327, gate, e2e 671/671. The new message checked against a stub server answering 500. check-in: b4637e26df user: dmestas tags: issue22-diagnostics | |
| 21:15 |
merge logstore-slice2: the open-cost work reaches production
openReadOnly is what every CLI verb opens through, and until now it still checksummed every payload byte on every invocation -- so nothing users ran had gotten faster. It scans header-only now: 10k frames 42 MB 42.2ms -> 10.3ms 10k frames 410 MB 298.0ms -> 12.3ms 100k frames 416 MB 511.9ms -> 115.4ms 100 frames 419 MB 321.4ms -> 0.10ms Open cost is a function of frame count rather than repository size. On a store of a few large artifacts it is effectively free. This completes the sequence: vendor a faster crc, measure the path production actually takes, scan header-only on the append path, ship the fsck verb, then delete the eager check. Each step was gated on the one before it, and the deletion was deliberately last because it is the only one that gives something up. What is given up: opening a repository no longer detects payload corruption. It surfaces on the read instead -- readFrameAt crc-checks every frame unconditionally now -- or on demand through "zit verify". That is the split git draws between ordinary operation and fsck, and it was signed off knowing the tradeoff. A damaged store also stays usable for everything that does not touch the damaged bytes, which the old eager check made impossible. Slices 0 and 4 are closed too: the 4 KiB chunk widening landed inside verify, the only place that loop still runs, and the sidecar index is dead on its own gate -- the 100k-frame shape was to justify it by exceeding 150ms and came in at 119ms. mmap is dead alongside it, separately, because a mapping would keep shunned bytes resident and readable until every mapping dropped, which defeats reclamation rather than merely slowing it. Neither rejection is recorded at the logstore extension comment yet; that is the one piece of follow-up this leaves behind. Verified on the merged tree: fmt clean, test 2327/2327, gate 19/19. On the branch tip, additionally e2e 671/671 and dst 6 seeds x 1000 steps. check-in: 6d9172d488 user: dmestas tags: trunk | |
| 21:12 |
logstore: openReadOnly scans header-only; verified_on_open is gone
The speedup reaches production. openReadOnly is the path every CLI verb takes -- Repository.open falls back to it, and Repository.logStore, the content channel behind cat, artifact, checkout and sync, calls it with no cache in front. It scanned every payload byte on every invocation: 10k frames 42 MB 42.2ms -> 10.3ms 10k frames 410 MB 298.0ms -> 12.3ms 100k frames 416 MB 511.9ms -> 115.4ms 100 frames 419 MB 321.4ms -> 0.10ms Open cost is now a function of frame count, not repository size. verified_on_open was the flag openReadOnly set to tell readFrameAt that the scan had already checked every frame, so reads could skip their own crc. With no scan doing that, the exemption had nothing left to rest on and the flag is deleted. readFrameAt now crc-checks unconditionally: it is the only thing standing between a rotted payload and a caller that believes it, and restoring any skip there means restoring the scan. Detection is relocated, not removed. A payload bitflip no longer voids the open -- the rest of the repository stays usable -- and surfaces the first time anyone reads those bytes: "zit cat" and "zit artifact" both refuse with 'repository is corrupt', and "zit verify" names the artifact and the offset. That verb shipped first for exactly this reason. openForVerify is deleted. It existed because openReadOnly both aborted on a damaged store and granted read-time trust, neither of which verify could accept; both properties are gone, it became byte-identical to openReadOnly, and its own doc comment said to remove it in this commit. The DST prove-it-can-fail arm is inverted rather than deleted. It asserted that openReadOnly WOULD serve latent rot silently, across a seed range where at least one corpus lands a payload rot on a raw frame whose header survived. That hazard is gone, so the same range now asserts the opposite: the read path never serves rot as success. trust_on_read goes with it -- there are no longer two paths to select between. Losing the eager whole-log scan cost nothing here, because per-read verification is what was catching this all along. Net -40 lines. Verified: fmt clean, test 2327/2327, gate, e2e 671/671, dst 6 seeds x 1000 steps. The Slice 1 tripwire -- the assertion that openReadOnly still rejected eagerly, written to fail if the flag was deleted without an fsck verb -- fired on this commit, which is the one it was aimed at. closed check-in: b224ffeb40 user: dmestas tags: logstore-slice2 | |
| 20:55 |
import-git: pack the refs, and split the budget they shared with payload
zit now imports sqlite at defaults: 36,416 commits, 2,109 refs, 44m35s, no flags. Two independent walls stood in front of that. Refs (issue 21). One file per ref made a ref's name a path, so the filesystem got a vote on which repositories are representable. sqlite has refs/remotes/origin/EQP-improvements and .../eqp-improvements at different commits -- legal, distinct git refs -- and on APFS the second landed on the first as a bare PathAlreadyExists, raised during publication after the whole import had run. Case was only the reachable instance; path length, reserved device names and each platform's own Unicode fold table are the same bug in other clothes. They are one sorted file now, "refs", whose lines pair a canonical name with its target. Nothing reads it back -- "refs" is a reserved root the working-tree scan skips, and branches resolve through repostate's sym- tags -- so it is interop output and its job is to be legible. Sorting makes it byte-identical for a given ref set, so two imports of one repository can be compared. This supersedes the ASCII-fold refusal from the previous commit. That was the right interim while a ref was a filename; with one file the collision is representable, so refusing it would be gratuitous. What survives is the genuine collision -- distinct sources normalizing to a byte-identical canonical name, refs/heads/./release against refs/heads/release -- which no layout can hold, and which now names both sources instead of only failing. Budget (issue 20). The 4 GiB payload default refused sqlite's 12.2 GiB stream, and could not simply be raised: fileCommandCeiling divided the same number, so a larger allowance for a whole history was also a larger allowance for one commit's retained command list. Those bound different things and only one is memory -- payload streams and is released per event (sqlite's import held 21 MiB), while a commit's command list is held whole. The arena has max_command_arena_bytes of its own now and keeps the old 4 GiB; the cumulative total goes to 1 TiB. The file-command ceiling is therefore structural: about 104 million commands in one commit, and no flag raises it. Its diagnostic says so rather than pointing at a knob that no longer moves it. The help text claimed the payload flag fixed both; it only ever fixed one. check-in: 8ae7041c2c user: dmestas tags: sub-manifest | |
| 20:54 |
merge logstore-open-cost: header-only open scan and the verify verb
Opening a repository checksummed every payload byte, so open cost scaled with the whole store rather than with what the caller asked for. The append path now scans header-only: 10k frames 42 MB 42.2ms -> 11.2ms 10k frames 410 MB 298.0ms -> 12.7ms 100k frames 416 MB 511.9ms -> 119.2ms 100 frames 419 MB 321.4ms -> 0.10ms openReadOnly -- the path every CLI verb reaches -- deliberately still verifies payloads. Nothing has moved on the production path yet. That is Slice 2, and it is gated on this merge's other half: zit verify, the on-demand fsck that has to exist before eager detection is removed. The 100k-frame run settles a question the plan left open. Slice 4's gate was 'build the sidecar index only if that shape exceeds 150ms after the scan change'; it lands at 119ms. The sidecar is dead, and mmap with it -- separately, a mapping would keep shunned bytes resident and readable until every mapping dropped, which defeats the reclamation guarantee rather than merely slowing it. Both should be recorded as evaluated-and-rejected at the logstore extension comment; that is not done here. Also adds --mode append|read-only to the open benchmark. Every open number this effort had produced measured LogStore.open, which no CLI verb enters; the two modes agreed within noise before the scan change, which is what makes the after numbers above a comparison rather than two guesses. Verified on the merged tree: fmt clean, test 2327/2327. gate 19/19 and e2e 671/671 were watched green at the branch tip, which is content-identical -- trunk did not move between the branch point and this merge. check-in: 90bd1b8015 user: dmestas tags: trunk | |
| 20:52 |
verify: the explicit fsck verb
Slice 2 will extend header-only scanning to openReadOnly and delete verified_on_open, which removes eager corruption detection entirely. This is the on-demand replacement that has to exist first -- the same split git makes between normal operation and git fsck. Two passes over the store. FrameVerifier walks every frame under .verify_payloads, which is the mode the open paths are shedding; it is built on StorageFile rather than an opened LogStore, so it still works on a store that will not open -- the case an fsck exists for. Then openForVerify opens .headers_only and reconstructs each artifact, which catches a frame whose checksum is honest about bytes that are not what their name claims. Both passes report rather than abort. A payload CRC fault carries a header-proven frame_len, so the walk steps over it and inventories the rest; only a header-window fault, whose length cannot be trusted, stops the walk. A torn tail is not a fault -- open recovers it silently, and it means an interrupted write, not rot. An unreadable payload is a finding too, not the end of the inventory. Exit codes stay 0/1/2 as every other verb documents them; 2 remains usage error and never corruption. Findings stream to stderr as they are found, the clean summary goes to stdout, and on any fault stdout is empty. Scope stops at content artifacts. A manifest whose bytes hash correctly is byte-identical to what commit wrote and what sync validated on ingest, so re-parsing it would catch a zit bug rather than storage rot. The help text says so. openForVerify is deliberately not openReadOnly: openReadOnly sets verified_on_open, which makes readFrameAt skip the per-read whole-frame CRC, and it scans .verify_payloads, so on exactly the damaged store an operator is fsck-ing it aborts before pass 2 runs. Slice 2 makes the two identical and openForVerify is deleted in that commit; its doc comment says so. The chunk buffer in scanOneFrame's payload loop widens from 4 KiB to 64 KiB, matching cloneInto's existing precedent. Post-Slice-2 that loop runs only under verify, where an operator's wall-clock is what it costs. zit verify is not an RFC-mandated verb. repo-state 7.3 blesses full rebuild as a fallback, but the verb's shape is a local design decision, not a protocol requirement. Verified: fmt clean, test 2327/2327, gate 19/19, e2e 671/671. Four mutants, all compiling, each red for its own semantic reason: pointing the frame pass at .headers_only, stubbing artifact.verify to true, dropping the unreadable-store tally, and reverting the payload read to a hard error. Exit arms smoke-checked against the built binary. closed check-in: bcb9f8eb6e user: dmestas tags: logstore-open-cost | |
| 19:25 |
import-git: refs that differ only by case collide, and say so
sqlite carries refs/remotes/origin/EQP-improvements and .../eqp-improvements pointing at different commits. Both are legal, distinct git refs. Zit stores a ref by creating a file at its canonical path, so on APFS the second one landed on the first: bare PathAlreadyExists, raised by writeRefs during publication, after 49 minutes of completed import. No ref name, no cause. The refusal already existed. finalizeRefs rejects two source refs that resolve to one canonical leaf -- 'refs/heads/./release' against 'refs/heads/release' is a tested case. It just compared bytes, and a filesystem does not. Keying the canonical index by an ASCII fold makes the existing rule see the collision it was written to catch, uniformly, instead of leaving it to whichever disk the import ran against. ASCII rather than Unicode is deliberate and documented at the fold: APFS, HFS+ and NTFS each fold a different frozen table, so following them would make the importable set depend on the volume. A-Z is what they agree on. The error now names both source refs, both canonical spellings, and whether case alone separates them -- a normalization clash and a case clash want different fixes from the operator. Retained on the importer and formatted by the CLI, the same split FileCommandOverrun uses. This refuses on Linux what Linux tolerated, which is the point: the repository means the same thing everywhere or it is not portable. Making sqlite importable rather than merely diagnosable is a layout question -- one file per ref cannot represent it -- and stays open. check-in: f0331d2f5a user: dmestas tags: sub-manifest | |
| 19:24 |
logstore: scan header-only on the append path
The open-time scan streamed every payload byte through the crc, making open cost O(repository bytes) rather than O(what the caller asked for). ScanMode.headers_only validates each frame's header window and then steps over the payload by frameSpan arithmetic, leaving open cost O(frame count). Measured, same binary, both modes in one run: 10k frames 42 MB 42.2ms -> 11.2ms 10k frames 410 MB 298.0ms -> 12.7ms 100k frames 416 MB 511.9ms -> 119.2ms 100 frames 419 MB 321.4ms -> 0.10ms Every fault that lives in a header window is still caught: readFrameHeader runs unchanged, so a bad prefix crc, a bad header crc, an out-of-range hash_len and an unknown flag kind all still void the open. Torn-tail detection survives intact -- a torn frame is a clean prefix, never damaged bytes, so offset + frame_len > total catches every truncation the payload and trailer short-read checks used to, using a length the header window already proved trustworthy. What is no longer caught at open is a bitflip in already-landed payload bytes, which becomes content()'s job; it crc-checks the frame it reads regardless. openReadOnly -- the path every CLI verb reaches -- deliberately keeps .verify_payloads. No guarantee has moved on the production path yet, and none should until the explicit fsck verb exists to replace it. This half is what makes the cost measurable without taking on that decision. The one test that changed is the one that had to: a corrupted payload on a complete frame no longer voids open. It now asserts the fault surfaces on the first content() read AND that openReadOnly still rejects the file eagerly. That second assertion is the tripwire for deleting verified_on_open without shipping the verb first. check-in: bae910f00e user: dmestas tags: logstore-open-cost | |
| 19:23 |
bench: measure openReadOnly, not just open
benchOpen timed LogStore.open -- the append path. Production never takes it: Repository.open falls back to openReadOnly, and Repository.logStore, the lazy content channel behind blob, cat, checkout and sync, calls openReadOnly with no cache in front. Every open number this effort has produced described a path no CLI verb enters. That was survivable while the two paths ran the same scan. It stops being survivable the moment either one scans header-only, at which point the bench would report the optimized path while production still paid the full cost. --mode append|read-only defaults to append so existing invocations report unchanged numbers, and the mode is in the output line because the two are only comparable when a captured log can tell them apart. Calibration on this tree, before any scan change: the modes agree within noise on all four shapes -- run B is 1% apart. That agreement is the check that the flag measures what it claims. check-in: e56a663b7a user: dmestas tags: logstore-open-cost | |
| 19:06 |
import-git: the payload refusal names its numbers
ExportPayloadLimitExceeded was a bare error name. Importing sqlite cost 17 minutes to learn a single integer, and the operator still had to guess it: the stream is 12.2 GiB against a 4 GiB default. One error carried two unrelated bounds. max_total_data is the operator's --max-export-payload-bytes; max_data is a fixed 1 GiB per-payload ceiling no flag reaches. Reporting the wrong one sends an operator to a knob that cannot help, so PayloadOverrun records which bound refused, and the single -payload arm says plainly that no option raises it. The cumulative arm refuses to name a remedy value. Parsing stops at the overrun, so the observed total is a lower bound; 'above 9' would name a number that fails again. It reports the bound and says the full payload is larger, which is the honest shape of what the parser knows. Also corrects the budget's own doc comment, which claimed four GiB was 'the working set an import may claim on a commodity machine'. Measured resident size importing sqlite's 12.2 GiB stream: 21 MiB. Payload is streamed and released per event. What the cumulative half actually bounds is total history, so the default over-provisions memory by three orders of magnitude while refusing a mainstream repository. check-in: ee703cde16 user: dmestas tags: sub-manifest | |
| 04:56 |
merge trunk: shun, the release-assert guard, and the split test lanes
Trunk since d186a44e brought the repository shun table and its whole- branch verification fixes, the release-assert guard, the `test`/`gate` lane split, the structurally bounded logstore priority window, and the vendored crc32. Eight files conflicted. The decisions worth naming: Lanes. `git-import-e2e` was a compile-only proof hanging off `test`; that is exactly the cost trunk moved to `gate`, so it moves with `e2e` and `smoke`. `import-git-bench` stays on `test` because it registers a real unit-test run rather than a compile proof. `declared_steps` is the union plus `gate`. Sync ingest. Both sides rewrote it: trunk added the §7.7 receive-side shun admission, this branch added §7.1 phantom minting for a pushed tree's missing sub-manifests. They compose in one direction — admission is asked first, so a card §7.7 refuses is never stored and mints no phantoms either. The S references of an artifact this repository declines to hold are not names it should then go begging for. Client construction. Trunk factored the three verbs' `Client` literal into `syncClient` and threaded `RepoAdmission`; this branch had widened `project_code` into `RepoSyncIdentity`. The helper now carries both, which is what trunk's own comment asked for: three copies of that literal is how a repository filter comes to be wired into two verbs and not the third. Per-round server refresh. Trunk made the snapshot mandatory — `error.ServerStorage` rather than serving with no shun table — where this branch read its four sub-manifest fields through fail-open defaults. Trunk's reading wins for all six fields: serving a stale default is the failure, not the fallback. Two guards trunk owns caught real defects in this branch's code, which is the whole point of them: The total switch in `shunConsequence` refused to compile against the `submanifest` artifact type, exactly as its comment promised. It has an arm now. The release-assert guard found nine asserts on untrusted input across six functions. Two were reachable faults, not style: `lookupTree` asserted §4.4 on a sub-manifest a peer supplies, so a malformed node panicked a ReleaseSafe reader and was walked anyway under `--release=fast`. It returns `SubUnresolvable` now, which §5.2 already defines as "absent, or is not a SUBMANIFEST". The empty-S-run assert became an early `null`. The two sub-manifest writers refuse an empty group through a new `SubmanifestWriteError` rather than asserting; the builder's internal path keeps its allocation-only surface through `serializeLeafCore`, since the partitioner is what establishes non- emptiness there. The rest were tautologies or restatements of invariants the card parser already enforces, and now say so in a comment instead of carrying a panic. Verified on the merged tree: `gate`, `e2e`, `dst` (6 seeds x 1000 steps), `smoke` (S1-S10, including trunk's new shun interop), and `git- import-e2e` all green, `zig fmt --check` clean. Beyond the suite, the importer was run for real: the empty-email repro imports and its check- in carries `U mohye77`, and marktext imports end to end with the analyzed pin at k=5. check-in: 562cf5be74 user: dmestas tags: sub-manifest | |
| 03:13 |
fast-export: accept an author with an empty email
parseIdentity refused an identity carrying an empty address twice over, once as `open + 1 == close` and again as `email.len == 0`. That is not malformed input. Setting `user.email` to the empty string is ordinary git configuration; `git fast-export` writes the identity with an empty angle-bracket pair, and `git fast-import` reads that same stream back to the same commit. The construct is valid by git's own definition on both sides of its own tooling, and we rejected it. The cost was not cosmetic. Two such commits, reachable only through a merge's second parent, aborted the import of an entire 437 commit repository with `import-git: import failed: Malformed`, naming neither the line nor the rule. Only the email clauses are relaxed. The name clauses stay: git's own porcelain refuses an empty ident name, and the importer maps the identity's name onto the U card login, so a nameless identity would produce a nameless check-in. The two questions were conflated in a single condition, which is a large part of why this went unnoticed, so the guard now separates them and says why. This is the third defect on this branch of the same shape: a property of our own fixtures promoted into a rule about the world. The first two were bounds tuned to the width of our benchmark corpus. This one is sharper, a refusal of input the tool we integrate with emits by default. Every identity fixture in the parser tests carried a non-empty email, so a uniform corpus could not falsify a rule that keys on emptiness. Both tests are witnesses rather than decoration: each fails with Malformed against the previous parser and passes against this one. The unit test pins the construct and the empty-name refusal beside it; the differential test has git itself produce the commit and then asserts the imported check-in carries the login mohye77 rather than an empty user. check-in: 1f21f96558 user: dmestas tags: sub-manifest | |
| 02:56 |
git-import: translate a Git fast-export stream into a zit history
`zit import-git SOURCE DEST` reads a Git repository through `git fast- export` and builds a zit repository from it. The destination appears only after the export, artifact creation and publication have all completed, so a failure anywhere leaves no half-published repository behind; this is the first caller of the logstore artifact batch added earlier on this branch, which is what makes a check-in's blobs arrive as a unit. Mode 160000 now parses as a gitlink instead of error.UnsupportedMode. That completes the split begun in the previous check-in: reporting what the stream said is the parser's job, and deciding that zit has no submodule concept is the importer's. The refusal still happens, but it now explains itself instead of failing as a parse error on input git legitimately emits. Signed commits are refused by default rather than imported with the signature silently dropped. Fossil has no signature card, so importing one is a loss the operator should choose; --drop-signatures accepts it explicitly. The sub-manifest parameter is analyzed, not guessed. Preflight measures the source's own name set and pins its measured optimum, and --submanifest-k overrides that only to match another repository's existing pin, which is what lets two repositories share sub-manifests by content address. On a 100,000 file corpus the analyzed value cuts the objective by roughly 3.4x against the fixed default it replaces. Correctness is checked differentially against real Git rather than against fixtures we wrote: every materialized tree is compared to `git ls-tree` for the corresponding commit, refs and annotated tags are compared to the source, and both comparators now assert that a gitlink is absent rather than merely tolerating it, so the omission is a contract instead of an accident. The rest is wiring. extras learns the import provenance names, root.zig exports the importer modules across the executable and library boundary, tidy and the CI step guard learn the new test roots and build steps, and the e2e help check learns that a verb spelled import_git in Zig is import-git on the command line. check-in: dc0aa2f858 user: dmestas tags: sub-manifest | |
| 02:43 |
build: split the unit lane from the full gate
zig build test was 9m13s, and the tests it actually ran were a small fraction of that: it also compiled the e2e and smoke harnesses in the pinned ReleaseSafe and then never executed them. test is now unit tests only -- 2m54s cold, 1m42s warm -- and the new gate step carries those two compile proofs. The dst, bench and smoke UNIT tests deliberately stay on test. They are unit tests despite their siblings' names, zig build dst/smoke run the simulation and the interop check rather than them, and CI invokes the lanes one by one and never invokes gate -- so moving them would have dropped them from CI while every step still looked green. ci_test's declared_steps records gate as intentionally not-in-CI, and names that failure mode for the next lane someone adds. check-in: 7363238ad5 user: dmestas tags: trunk | |
| 02:39 |
submanifest: incremental apply must equal canonical construction
The incremental tree editor diverged from buildTree on realistic filename distributions. OldTreeCursor.appendPrefixAtHeight read the retained native-prefix list without consuming it, so one descriptor could be spliced into two different output levels: buildUnpublished placed the height-1 prefixes into the leaves, and mergeRootPrefixes then prepended the identical descriptor again before finishRootLevel. The emitted interior node carried the same S key twice, which fails artifact.parse's ascending-S guard, so an import aborted partway through a real history with a SyntaxError raised by our own grammar. finishCheckpoint already guarded its own splice with a height test. That guard was an ad-hoc workaround for this same double-append, and mergeRootPrefixes never had one. The splice now consumes what it emits, so a retained descriptor lands in exactly one level and the guard becomes unnecessary rather than duplicated. Found by importing marktext, not by the suite. Every name corpus here was uniform, and a duplicated prefix descriptor requires names that share prefixes at differing depths, so 55 tests could pass over a defect that breaks real repositories. The new seeded differential sweep generates four corpora of 136 to 240 paths with mixed depth, shared directories, dotfiles and 1 to 43 byte basenames, over every supported k with twelve batches each, and asserts it reached a tree at least three levels deep so it cannot quietly degrade into testing flat roots. It diverges in 20 of 452 batches against the unfixed editor, every divergence unparseable. The decoded-root validation memo lands alongside it: repeated lookupTree validation against an unchanged root is answered once instead of rebuilt, which takes this file's own 56 tests from 1701 to 450 seconds over identical assertions. check-in: 4a95e436af user: dmestas tags: sub-manifest | |
| 02:28 |
fast-export: derive the per-commit file-command ceiling
Limits.max_file_commands_per_commit was a standalone number, and the caller pinned it to the width of our own benchmark corpus. That is a fixture size promoted to policy: no headroom, no way to raise it without a rebuild, and it refused legitimate histories. `git fast-export` lists an entire tree for a root commit and for any commit whose first parent was not exported, so the ceiling is reached by ordinary repositories whose initial commit lands a whole codebase at once. The bound itself is necessary. admitData is the parser's only byte admission and it sees the byte count in a `data` header alone; every file command additionally allocates a list slot and a duplicated path into the per- commit arena with nothing charging for them, so the command loop is otherwise unbounded against a subprocess pipe. It is now computed: fileCommandCeiling(limits) = max_total_data divided by min_retained_bytes_per_file_command, where that constant is @sizeOf(FileCommand) plus one -- a command's list slot plus the shortest path it can carry, the empty path being rejected. Nothing smaller is representable, which is what makes it safe to divide a byte budget by. The ceiling restates the arena authorization the caller already granted, in commands rather than bytes, and moves when that authorization moves. Refusal retains the observed count, the ceiling, and the budget that produced it, so the diagnostic names all three rather than only its own error. The remaining limits gain distinct names -- Data, Event, FileCommand, Line, Merge and Path -- in place of one LimitExceeded that could not say which bound was hit. Exact-count data may carry an optional trailing LF, so only the first following empty line is ambiguous with the commit terminator; readDataExact and readCommitData split that decision instead of guessing. check-in: 85d510d361 user: dmestas tags: sub-manifest | |
| 02:15 |
repostate: resolve a stored sym- selector directly
Names beginning with `sym-` are ambiguous in this repository. A user tag written `sym-x` is stored under the canonical spelling `sym-sym-x`, so resolving `sym-x` correctly looks for `sym-sym-x`. An imported repository, though, can carry a tag whose stored canonical spelling already begins with `sym-`, and nothing could address it: the ordinary lookup rewrote the name and missed the artifact that was actually there. Resolution now attempts the ordinary user-tag path first and consults the stored spelling only when that fails. The precedence is deliberate. A user tag continues to win over an imported canonical name, so no existing repository changes meaning, and the fallback is reachable only where the ordinary answer does not exist at all. check-in: fc9bcb81bd user: dmestas tags: sub-manifest | |
| 01:58 |
logstore: all-or-nothing artifact batch append
Callers publishing several artifacts together had only putArtifact, one at a time, so a failure partway left the store holding a prefix of the set with nothing naming what was missing. Each caller that cared wrote its own compensation, or quietly did not. putArtifactBatch takes the whole set and appends it under a single transaction: either every item is durable or the store is byte-identical to its prior state. ArtifactBatchItem carries the hash, the bytes, and an optional delta base, so a batch may mix full and delta-encoded members without the caller resolving storage order first. Nothing calls it yet. The importer, which needs a check-in's blobs to arrive as a unit or not at all, is the first caller and lands later on this branch. check-in: 7cf522fa5b user: dmestas tags: sub-manifest | |
| 01:41 |
control_paths: one worktree-path admission policy
Two layers decided independently what a reserved path is. update carried its own validateWorktreePath — an absolute/backslash/dot-dot check plus a hand-rolled case-insensitive comparison against the .zit family — while extras carried isControlName, which tested only the first /-delimited component of a path. They agreed by coincidence rather than construction, and the extras form admitted `.zit-managed/entries` because it asked about the component instead of the root. control_paths now owns both questions. validateWorktreePath and isReservedRoot are the single definitions; update delegates to the first and extras to the second. isControlName takes the full root-relative key that add and move already hand it, so a path rooted in a control entry is refused at any depth rather than only at depth zero. firstComponent goes with its last caller. The update test that pinned the old list is now a case table naming what is reserved and what merely resembles it, including the case-insensitive spellings the previous ad-hoc comparison happened to get right. check-in: 581db6e5ba user: dmestas tags: sub-manifest | |
| 01:26 |
update: materialize symlink worktree entries
A check-in's F card can name a symlink (§4.7.6-m5), but update refused one outright with error.SymlinkUnsupported, so any history carrying such a card could not be checked out, reverted, or updated to. The refusal was cheap to write and wrong: the reader already represents the entry, and only the worktree side lacked a branch for it. materializeEntry and writePayload now create the link from the card's target bytes instead of writing a regular file containing them, and isCurrentState compares against .sym_link and the recorded target rather than the size and mode of a file that is not there. The managed-set walks accept .sym_link beside .file, so a materialized link reads as a tracked entry instead of an intruder the admission check rejects. `zit revert` on a symlink card previously exited 1 naming the refusal. It now restores the entry, and the test reads the link back with readLink, which distinguishes native materialization from a regular file that merely holds the target's bytes. check-in: bb96853a0f user: dmestas tags: sub-manifest | |
| 01:24 |
build: forward --test-filter to the e2e test root
The e2e root is one test binary holding every scenario, and its runner took no filter, so narrowing to a single scenario meant editing build.zig or paying for the whole root. `zig build e2e -- --test-filter NAME` now compiles the filter into the test artifact, accepting both the two-argument and the `--test-filter=NAME` spellings and passing nothing through when the arguments do not name a filter. check-in: cd70f19286 user: dmestas tags: sub-manifest | |
|
2026-07-30
| ||
| 21:47 |
release_assert: a guard against assert used as input validation
std.debug.assert is right for an internal invariant and wrong for a rule about data the function did not produce, and it is wrong in two different ways. Under the ReleaseSafe default it survives and PANICS: on the write path that is the trade we want, a crash over a durable bad frame, but on a peer-reachable path it is a remote denial of service. Under an explicitly chosen --release=fast it compiles to nothing and the input flows through unvalidated. One fix, a checked error, closes both. Flags an assert only when all of: the fn is pub, returns an error union so a rejection was already available, the assert names one of its own parameters, and no earlier return in the body already rejects on that parameter. That last rule is what makes it signal -- delta.applyCopy returns MalformedDelta and then asserts to restate it, which is the CORRECT shape and must not be flagged. The baseline is keyed on file, function and COUNT, never on line numbers: line numbers churn on every edit above them, while a count still catches a second bad assert added to a function that already has one. It fails in both directions, so a fixed finding must be removed from the list and the list can only shrink. Existing findings are recorded, not fixed. Triage belongs in issue 19, where the ReleaseSafe default already reduces the write-path subset to crash-instead-of-corrupt and leaves the sync-side ones as the live exposure. check-in: 949e2b5542 user: dmestas tags: trunk | |
| 21:20 |
build: --release means ReleaseSafe, and pin it
src/ carries 1086 std.debug.assert calls. They are how a detected inconsistency becomes a crash instead of a durable write -- TigerStyle's 'downgrade catastrophic correctness bugs into liveness bugs' -- and that trade exists only while the assertion does. ReleaseFast and ReleaseSmall compile every one to nothing, so a binary built that way converts each guarded corruption into an unguarded one, in a store where a bad frame is permanent and a crash is not. Measured, not assumed. Read-side verbs are indistinguishable between the two modes: status, log and stat all sit inside noise on a 5 MB store. zit pack is not: 599ms fast against 15.2s safe, about 25x, because the delta encoder's best-of-N search is a tight per-byte loop where a bounds check is a large share of the work. That argues for setRuntimeSafety(false) on that measured loop -- local, reviewable, over code the fuzz and DST lanes already cover -- rather than stripping assertions from every other path in the binary. Not taken here. Note the option surface changes: stating a preference replaces the -Doptimize enum with a -Drelease bool, so the deliberate escape hatch is the top-level --release=fast. -Doptimize=ReleaseFast no longer parses. ci_test pins the preference, and says that if the project ever decides to ship unchecked, the pin should be deleted in that same commit so the decision is visible in history rather than arriving as drift. check-in: 4379187a10 user: dmestas tags: trunk | |
| 20:21 |
logstore: bound the priority window structurally, not with an assert
gatherCandidates capped the entries ADDED from the caller's priority slice, not the elements read: a duplicate hits ridListed and continues without advancing n, so the fill check never fires and the walk runs the caller's whole array with a linear dedup scan at each step. The only thing holding that back was an assert that the slice fits the window -- and std.debug.assert compiles to nothing in ReleaseFast and ReleaseSmall, so there was no guard in exactly the build where an unbounded scan would matter. Slicing the prefix holds in every mode, and turns an over-length slice from a precondition violation into defined behavior: accepted, tail ignored. Both callers today are inside the window either way. Two tests, both mutation-checked against the old shape. What is NOT pinned is the duplicate-heavy case: dedup leaves the surplus slots to the chronological walk, so the candidate set is identical either way and only the count of elements read differs. That is a cost property, not a behavioral one, and no test through this API can catch it -- the header comment says so rather than letting the pair read as covering it. check-in: bfec65b1a1 user: dmestas tags: trunk | |
| 18:47 |
merge shun-implementation: shunning and space reclamation
A repository-local shun table (sync SS3.3), the zit shun verb over it, and the four suppression directions the drafts require: never requested, never announced or served, an arriving copy discarded, and the bytes reclaimed on demand through the existing atomic rewrite. Built as eight slices, each through design review, TDD, spec audit, three review lenses, mutation proof and a full gate. Reading the eight as one change afterwards found what none of them could alone: a peer-reachable assert that crashed the client on a malformed DELTASRC, and a pre-rename guard that re-stat'ed a stale handle so two concurrent rewrites could un-shun each other. Two defects predating the feature are fixed on the way. FileStorage.sizeImpl answers from a cached watermark, so pack's tail-revalidate compared a key against the number it was derived from and could never abort -- the guard had never worked. And a malformed file card could trap the client through PendingDeltaQueue. Format version stays 1: an older binary meets an unknown frame kind and rejects cleanly, which is what it should do with a tombstone it cannot read. No interchange surface changed -- smoke covers ten fossil 2.28 scenarios including a fossil clone of a defer-shunned store and a real fossil push of a shunned artifact. Known and recorded, not blocking: a running zit serve keeps serving the old inode until restarted after a --force reclaim swaps generations. check-in: e57bcceadf user: dmestas tags: trunk | |
| 18:32 |
shun: whole-branch verification fixes
Read as one change rather than eight slices, which surfaced defects no per-slice review could see. A peer could crash us. ShunSet.contains asserted its key domain while its own vtable contract promised an out-of-domain name answers false and never traps -- and a 'file HASH zz SIZE' card reached it. It is a checked rejection now, and receiveDelta refuses a DELTASRC that is not a complete artifact name before one can mint a phantom, which also closes a pre-existing PendingDeltaQueue trap on the same card. Two rewrites could un-shun each other. rewriteAndSwap's pre-rename guard re-stat'ed the caller's handle, which after a competing rename points at the old inode -- so the guard passed and the loser silently reverted the winner. It reopens .zit by name under the temp lock now, as shunAppendRows already did. The client snapshot lacked the masked-basis guard the serve path has, so a kept delta rooted on a shunned base could be handed out in delta form. shun_help denied two slices that landed after it was written: it still told operators a shunned artifact is only suppressed in one direction and can be re-announced back into the store. Also: storestat's shun rows and bytes are wired into zit stat rather than left unused, so the printed breakdown still sums to the file size; the clone-send half is relabelled an erratum candidate against SS8.1/SS8.2 instead of claiming SS9.5, which is scoped to non-clone; and three stale doc bullets now describe the code that exists. closed check-in: d9578c6c6e user: dmestas tags: shun-implementation | |
| 17:20 |
shun S7: the shun+reclaim crash sweep
A DST sweep that shuns artifacts (forcing a delta base, a manifest and a mid-chain artifact across seeds), reclaims through rewriteAndSwap, and cuts the run at sampled write/sync/rename boundaries. The oracle classifies the recovered store as the PRE image or the POST image and fails on anything else. It earns its place: deleting packLog's pre-rename fsync is caught by this sweep and by nothing else in the suite. The concurrency-guard boundary is a second, DST-shaped pin on S3's fresh-stat fix rather than first coverage, and the module header says so. Two oracle clauses are graded honestly as tripwires, not coverage. Surviving content is re-hashed by packLog before its own commit, so a defect that would corrupt it aborts the rewrite instead and the sweep just sees the PRE image; no mutant can make that clause fire. The PRE half of the third clause is subsumed by the byte-equality check. Both are kept cheap and labelled. rewriteAndSwap and RewriteError are now pub: the property under test is a property of that sequence and reaching it needs the seam. faulty_dir gains fileSetLength and fileClose wrappers, deliberately ungated so no sibling sweep's cut numbering shifts. The default dst gate grows about 11s (~9%) for 12 sampled cuts per seed. check-in: cb22503707 user: dmestas tags: shun-implementation | |
| 14:53 |
ignore the crash-leftover working files: dst workspace and pack temp
.zit-dst-checkout-ws/ is the process directory the crash-recovery sweeps make their throwaway workspaces under. It is created at run start and removed by a defer at run end, so it is normally invisible -- but a killed or crashed sweep leaves the whole tree, .zit stores and all. .zit-pack-tmp is the fixed-name temp pack and shun --force write the new generation into before the atomic rename; a run that dies between create and rename leaves one beside .zit. Both are dotfiles, so a bare 'fossil extras' skips them and the tree reads clean while they sit there -- the same blind spot the .agent-config glob closed. Pinned by packaging_test. check-in: 9b95e3b9e5 user: dmestas tags: shun-implementation | |
| 13:15 |
shun S6: receive-side admission, asymmetric as the spec has it
SS7.7 L1434-1452, as one combined predicate: a target is shunned for admission when its complete name is in the shun table OR shun-sha1 applies to its SHA1 name. TargetAdmission.admits carries the whole rule, including the two card rules -- a clone file card BYPASSES the check and is stored; everything else consults it. That asymmetry is deliberate and is the applied text. zit's own erratum argued the discard should apply in clone too and four reviews rejected it against fossil's xfer.c. A test pins the bypass so a later reader does not 'fix' it back into the shape that was refused. For cfile the SS7.5 private discard runs first and the raw artifact-card count still increments -- both satisfied by placing the check below code that already existed, not by adding any. Admission is target-only. A shunned DELTASRC is not independently rejected: a held basis still serves, and an unavailable one falls to ordinary dangling-delta handling whose phantom SS3.3 already excludes from gimme. The planned e2e did not pin anything and was rewritten. A real zit push passes with the whole check removed, because transfer is receiver-driven and SS3.3 already stops this server begging for a shunned name. The shipped test POSTs an UNSOLICITED push+file container at a live serve -- the only shape that reaches this code -- and it goes red without it. Smoke S10 is real interop coverage but cannot pin this slice for the same reason, and its header says so rather than letting a green be misread. check-in: 620ed6a9c3 user: dmestas tags: shun-implementation | |
| 10:34 |
shun S4: stop advertising what the shun table names
LogIndexStore keeps a shunned holding in its enumeration and MASKS it rather than removing it, so a shun row appended between two rounds of one clone cannot renumber the survivors under the pager's cursor. held() answers false, the name leaves by_name, and SS9.5's 'igot for every eligible holding after the SS3.3 filters' is where that mask lives. The plan overstated what was missing. S1's log-level mask already nulled content and wire, so a clone silently skipped a shunned artifact before this slice. What was actually broken is narrower and worse: because by_name still resolved the name, emitCloneReply's basis proof fired in a safety build and the SERVE PROCESS PANICKED -- a fossil clone got 'server did not reply', not an absent artifact. Reproduced against fossil 2.28 and pinned as smoke S9. That also means the planned e2e ('a clone does not receive it') was never mutation-sensitive: it passes with this slice fully removed. The observable that pins the slice is the ABSENCE of an igot card naming a shunned artifact in a non-clone round's inventory walk, which now has its own test and a second round in the raw-HTTP e2e case. Smoke uses a defer-shunned fixture, not the planned packed one: pack rebuilds the delta chains and so removes the exact hazard the check exists to catch. logstore.zig carries a comment-only change: LogStore.shunned's doc claimed two questions were open that S4 and S5 have since closed. check-in: 230c2bbebe user: dmestas tags: shun-implementation | |
| 03:50 |
shun S3: the zit shun verb
Refuses by default and names what would be destroyed; --force proceeds; never prompts. Reclaims by default -- one rewrite for N hashes, which is why the usage takes HASH... --defer records the tombstone without rewriting, and is also gated by --force: the row is irreversible and S1's mask hides the content immediately, so it is not the safe half of the verb. The check-in this checkout is on cannot be shunned at all, with no override. runShun does NOT reuse runPack's plumbing verbatim as planned -- it diverges on the filter (the source's deferred rows UNION the newly named hashes), on writing tombstones into the new generation, and on its renderer. The shared body is extracted as rewriteAndSwap instead. Fixes a dead concurrency guard in that shared body, which predates this slice. FileStorage.sizeImpl returns a cached watermark rather than stat'ing, so validateTail was comparing a key against the number it was derived from and could never abort. pack carried the same dead guard. rewriteAndSwap now stats fresh at the check point; without that, this slice's claim that an aborted --force leaves no tombstone was unprovable. Consequence lines carry no counts or filenames: 'N descendant check-ins' and a blob's path both need a reverse F-card walk over every manifest, which is too much for the cheap refusal path. Deferred, not faked. No sub-manifest row: that type and its draft are not on this branch. shunConsequence is a total switch, so a ninth type will not compile without its diagnostic. check-in: be67814135 user: dmestas tags: shun-implementation | |
| 02:45 | feat(submanifest): add parser and sparse editor check-in: 8ca8c86174 user: dmestas tags: sub-manifest | |
| 00:39 |
shun S2: pack reclaims what the shun table names
packLog takes a borrowed Filter; a dropped rid is not copied, PackStats.dropped counts it, and packVerifyShunned refuses to swap in a store still holding a digest the filter was told to remove. keep_all is the identity, so every other caller behaves as before. The check runs BEFORE reconstruction and before the SS7.7 verify. A shunned artifact may be unlawful or simply broken, and verifying it would abort the very operation meant to remove it. Two tests pin the ordering: one frame whose content no longer hashes to its name, one that will not decode at all. The second closes a hole the first left in deriveCandidateHints. putArtifactPacked's floor_span became optional. A delta whose base the filter dropped has no keepable frame, and 'I have no floor' is a different claim from 'my floor spans N bytes'; an in-band sentinel could not tell that from a miscomputed span. pack_help no longer overclaims: damaged CONTENT does not block the reclaim, a damaged CHECKSUM still does, because the open-time pre-flight covers the whole store before any reclaim runs. Merges S5's e2e block alongside this slice's. Fossil's suggested resolution would have dropped this one. check-in: 341009e699 user: dmestas tags: shun-implementation | |
| 00:25 |
shun S5: never gimme a name in the shun table
sync SS3.3 L527-530 excludes shun-table names from the ordinary phantom scan. The predicate already existed and its call site hardcoded false; S5 supplies the durable state through a borrowed ShunSet whose .none identity leaves every existing construction site unchanged. The literal reading is kept: an igot for a shunned hash still CREATES the phantom (SS3.3 L511-514) -- it is simply never requested, so the phantom is inert and permanent. Three consequences the reviews forced out: A shunned phantom no longer counts as unresolved. It was told about and refused, not asked for and withheld; counting it made zit pull fail on every future pull, since the peer re-announces each round. The server consults the set too. SS3.3 states the filter on the phantom scan without qualifying a side, and a server under push-receive is a receiver, so an inbound igot could otherwise walk a shunned artifact straight back in. parseIgotCard now rejects a name that is not 40 or 64 hex, as parseGimmeCard already did (artifact-format SS3.2 L349-350). Widening the shun predicate to answer false for an out-of-domain name would have been the fail-open fix. check-in: 1c41f96e86 user: dmestas tags: shun-implementation | |
|
2026-07-29
| ||
| 21:21 |
shun S1: a shunned artifact reads as absent
content, contentCached and wire return null for a name in the shun table even while its frame is on disk, so reclaiming the bytes later never changes what a verb prints. The guard sits in the one shared read body behind a ReadMask the call site names, so 'the masked set is exactly these three' is compiler-checked rather than asserted in prose. It deliberately does NOT sit in contentAtOffset. sync SS7.7 L1445-1450 lets a held-but-shunned DELTASRC serve as a delta basis, and zit satisfies that for free because delta_of is a byte offset -- base resolution never re-enters the rid-keyed doors. A test pins it so a future tidy-up downward fails loudly. pack reads through an unmasked maintenance door: in this slice a shunned frame still copies forward, since dropping it is the S2 filter. pickBase probes through the masked door, so a shunned frame never roots a new delta. check-in: 3e5895dfb7 user: dmestas tags: shun-implementation | |
| 17:53 | merge: update submanifest work from trunk check-in: a9c5efeef4 user: dmestas tags: sub-manifest | |
| 16:54 |
shun S0: the durable shun table in the log
FrameKind.shun carries a tombstone keyed by the shunned artifact's whole digest (sync SS3.3 L518-525: persistent, repository-local, keyed by complete artifact name). Never interned, so lookup() still means 'the content frame for this hash'; scan rebuilds the set. putShun is idempotent and returns BadKey for any width but 20 or 32 -- checked, not asserted, so a ReleaseFast build cannot append a durable un-openable frame. pack carries the table forward. Without that the repo's own compaction verb silently drops every tombstone, which would make this slice's durability claim false the first time anyone packs. The pack FILTER is still S2. storestat and the benchmark restate FrameKind's universe; both now fold from the enum so a future kind fails to compile rather than vanish from the byte accounting. check-in: ce40b2526e user: dmestas tags: shun-implementation | |
| 16:39 |
rfcs: land the errata-applied sync protocol on trunk
Trunk carried the pre-errata draft. The corrected text -- the SS3.3 shun-table definition and the SS7.7 receive-side shun admission rule -- was only ever committed on the sub-manifest branch, so any branch cut from trunk was building against superseded normative text. This is the file the errata records name as applied (sha256 068d7672...). check-in: d186a44ea3 user: dmestas tags: trunk | |
| 16:31 |
ignore the editor-tooling classes: .agent-config/* and CLAUDE.local.md
.agent-config/ collects per-session tooling traces at the repo root. Being dotfiles they never appeared in a bare 'fossil extras', so they were held out of commits by fossil's default alone rather than by any rule -- one 'addremove --dotfiles' would land them permanently. CLAUDE.local.md is checkout-local operator config. Both are globbed now and pinned by packaging_test. check-in: a27cf4b4a9 user: dmestas tags: trunk | |
| 14:19 |
merge logstore-index: checksum the store through a vendored crc32
std.hash.Crc32 is byte-at-a-time over one table (~350 MB/s here) and the open-time scan CRCs every payload byte, so it was ~93% of a cold open. Slicing-by-8 in its place: a 416 MB store opens in 292ms against 1.22s, warm reads run 3.3x, the write path 1.5x. Bit-identical by construction, so no format changes and no repository is affected. check-in: 602c2459f2 user: dmestas tags: trunk | |
| 13:21 |
crc32: export for the benchmark and the fuzz lane
Both consumers live in their own build roots and cannot reach crc32.zig by path, so the export is forced. Adds the differential fuzz lane against std — the only coverage that can see a self-consistent but numerically shifted implementation, which would orphan every existing .zit file. The implementation brief prohibited this export, citing repocache as the precedent for unexported leaf modules. That precedent does not apply: repocache has no out-of-module consumer. storestat and repoimage, which do, are both exported. Overridden deliberately. closed check-in: c57c16c5cd user: dmestas tags: logstore-index | |
| 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-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 | |
| 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 | |
|
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:21 |
sim: working-tree crash-recovery DST sweep for update
Add sim/faulty_dir.zig, a decorator std.Io that cuts a run at a seeded mutating-op boundary the way a power loss does: the crashing write persists at most a torn prefix and every later mutation is a frozen no-op, so the checkout layer's own recovery moves no byte. Crash-point enumeration follows TigerBeetle's storage-fault-injection lineage, ported to the directory surface update.zig and checkout.zig write through. Add sim/checkout_sweep.zig: it materializes a check-in, replays an A-to-B update.applyCheckin under a crash at every reachable cut, reopens, and asserts unmanaged files are preserved, the checkout record names only a complete target, and the tree recovers to the target via the undo journal. Joins the dst gate per seed at bounded cuts; the soak walks every boundary. Finding: update.writePayload writes working files in place (createFile truncate, write, sync), not through the atomic write-temp+fsync+rename that checkout.write, the managed set, and the undo journal use. A crash mid-write can leave a file matching neither its pre- nor its post-image. The sweep pins this; a working-tree write-journal is the fix. closed check-in: 0cea021cf9 user: dmestas tags: h2-checkout-dst | |
| 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:13 |
Compose storage and network faults in the sync DST
The sync sweep and the storage sweep ran in disjoint PRNG streams off the same seed and never intersected: no exchange ever ran over a client store that could tear. A partial pull could leave torn frames that recovery accepts but a resumed session never converges past. runCrashResume backs the client store with a real LogStore over a tearable FaultyFile instead of the pure MemStore, seeded healthy then armed to tear. Between phase-1 rounds, on a seeded coin, it power-cuts the store mid-persist, reopens it, reloads the recovered prefix, and resumes with a fresh Client over the same durable store, mirroring cmd/sync's loadContentStore for wire-name to log-digest identity. The oracle keeps every existing sync invariant and adds the crash bite: post-recovery contents are a subset of the pre-crash held set, no corrupt frame crosses the boundary, and phase-2 liveness heals both domains before a fresh session converges in the bounded window (sync 3.7). One seeded PRNG stream drives the whole run, so a failure replays from the shared reproduce line. The composed-fault and liveness-under-faults discipline follows TigerBeetle's VOPR. closed check-in: d2b5f729ef user: dmestas tags: h3-crashsync | |
| 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:06 |
fuzz: floor outcome reachability across the fuzz targets
serve_fuzz already fails a run when its generator stops reaching the dispatch arms it exists to exercise, and merge3 does the same for conflict shapes. Generalize that guard so a generator that has drifted to one outcome can no longer pass every no-crash round while testing nothing on the arm it dropped. Six targets gain a measured reached-outcomes floor: delta decodeInt and targetSize (a decoded value vs MalformedDelta), artifact predicates (simple-pathname and wiki-name each accepting and rejecting), sync card parsers (push/igot/uvgimme each accepting and rejecting), logstore open (torn-tail recovery vs Corrupt), and transport deframe (a damaged container both decoding and being rejected). Every threshold is counted at a fixed seed, stated in the comment, and set below the arm's measured frequency; each floor gates above a minimum round count so short runs never trip it. Every other target carries a one-line note on why its outcome space does not partition: round-trips have only a success arm, and several garbage generators reach only their reject arm, the accept arm covered by the round-trip and deterministic tests. closed check-in: 1abe1089c1 user: dmestas tags: h9-fuzzcov | |
| 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 | |