Oregami
Repositories/oxedyne/fe2o3

Marks

1007 states somebody named, 4 marks the tool wrote

NameWhenOperationAuthorHistory before it
+Merge ore-a/help: fe2o3_syntax reads a command line and pages its help

commit bcde0df Ore's command line moves onto fe2o3_syntax (ore master 56abd5e). The syn branch brings the argv driver, Val and Arity, and paged help pages; on top of it, a message's words stay words, -- ends the options, an unknown verb names the app's help command, and a command's name on the wire still fills a value that is owed, so REPL and wire parsing are unchanged (12,545 lines compared against 04a9319). Callers migrated in the same window: daimond main 4271a973, oxegen master 5c2bb52, elearnity and oxesim2 (checked clean), oxesim and powls (patched; both were already broken for unrelated reasons). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X77Eb36vcie9zk2bf6oBMr

2 days agor1870400018:62298replica 1870400018276 operations, since the previous mark · compare with the head
+Merge ore-a/keys: save_secret is durable off unix too, and restrict_secret says what it narrowed

commit 04a9319 fe2o3_core. The non-unix save_secret fsyncs its temporary file before the rename, as the unix one does. restrict_secret returns the mode it narrowed a file from, or None when the file was already its owner's alone, so a caller can tell its user; it still warns. Existing callers are statements and are unchanged. Consumed by ore, whose signing keys are now written through save_secret (a crash mid-write no longer loses the old key). QA (keys_report_20260924.md): core file:: 5 -> 6; fe2o3_net and fe2o3_steel build; x86_64-pc-windows-gnu check; ore 400 -> 402. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X51QriBAsPx88T3JxFR8sU

7 days agor1870400018:62021replica 187040001811 operations, since the previous mark · compare with the head
+Merge ore-a/o3i: o3db never answers a read with another key's record, and closes in order

commit 22fe036 fe2o3_o3db_sync. A read checks the key and stamp in place against its cache entry, retrying up to eight times, then fails loudly tagged [Data, Mismatch] rather than return a same-sized record of another key that a collection moved (F1). The collection's move map is keyed by RecordDigest, not by offset (F2). While the disk is failing, syncs share one barrier under both interval and every-n policies (R1). A shutdown timeout never destroys queued messages (R2); start-up is guarded by catch_unwind (R3). A write that was appended but could not be confirmed durable carries the new ErrTag::Unconfirmed (R4, fe2o3_core). Close runs in stages: readers, scans and collectors; writers and syncers; cache; files; zone and config (a close under read load took 38.7 s, now 4.0 s). Adversarial QA twice (o3i_qa_20260924.md); re-tested after merging origin/main 55a4ca4: o3db 40 passed, sweep x2, main, oregami 54/54. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X51QriBAsPx88T3JxFR8sU

7 days agor1870400018:62009replica 1870400018170 operations, since the previous mark · compare with the head
+fe2o3_net: a chunked body's size and trailer lines are capped, not unbounded (part 1b)

commit 55a4ca4 take_line read a chunk-size or trailer line with no length limit and rescanned the whole buffer on every fill. Steel's HTTPS reader has no timeout once the headers are in, so one chunked request that never sends a line end grew without bound. A chunk-size line is now capped at a fixed HTTP_CHUNK_LINE_MAX (its own shape bounds it regardless of any configured limits); a trailer line is capped at max_header_bytes, the same bound already applied to the header block it is one more field of. Either overflow tags TooBig, so https.rs:981 answers 413 instead of growing the connection's memory forever. take_line also now scans only the bytes not yet searched (plus one byte back, for a CRLF split across a read) instead of the whole buffer each pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfPdMK6Q9DVi97WEvkHX37

7 days agor1870400018:61838replica 1870400018396 operations, since the previous mark · compare with the head
+Merge fix/linkring-wasm32: linkring's ring-size bound compiles on wasm32 (PV9)

commit af00b52 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwmZoGMPqSJ45iDEiy4VNb

7 days agor1870400018:61441replica 18704000181 operation, since the previous mark · compare with the head
+Merge ore-a/sto: o3db answers a write twice, starts only when ready, and collects what it owes

commit bae931c fe2o3_o3db_sync. A write is answered Written once appended, then durable under the sync policy (DURABILITY_TIMEOUT, 120 s); a slow disk no longer reports a landed write as failed. The barrier runs on a syncer per writer. start waits for every zone to survey and cache, bounded, and a failed start stops its bots before returning. Writer, zone, file and cache bots answer errors on the caller's responder. The collector reconsiders a file when it is sealed and when its last record lands, and in-bot supersessions go through the collection buffer. Callers draining a store's responder by hand must allow for Written: use Responder::recv_store_ack. Adversarial QA twice (sto_qa_20260923.md): o3db 36 passed, oregami 54/54, ochre index 33/33; fail-first on every new check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGGd317J7RTRrLjkELNEQT

7 days agor1870400018:61439replica 1870400018198 operations, since the previous mark · compare with the head
+Merge unit/u2: presentation verifier present/1, strict JSON reader, NonceTracker in fe2o3_net

commit 63c8f30 fe2o3_net::presentation (request, presentation, head, invoice and settlement shapes; the relying party's verifier over strict Ed25519 and linkring/1), ring behind a default feature, WebAuthn EdDSA on the strict verifier, NonceTracker moved from Steel into guard::nonce and holding a pair to max(stamp, now) + window, strict unpadded base64url in fe2o3_text, and Dat::json_canonical_without and Dat::decode_json_strict in fe2o3_jdat. Audited, with the audit's fixes. Correction to the U2 commits' claim: net's mode2-only fe2o3_crypto dependency keeps pq's C code out of a wasm32 build only. Natively fe2o3_iop_db still takes crypto with its defaults, so a native build of fe2o3_net still builds the C code and the jent_entropy link fault still needs its own fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwmZoGMPqSJ45iDEiy4VNb

7 days agor1870400018:61240replica 187040001894 operations, since the previous mark · compare with the head
+oreignore: exclude .wt worktrees, matching .gitignore

commit e588fef Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AJRTmhnm7JUWkKtLR7dGZ8

7 days agor1870400018:61145replica 18704000182 operations, since the previous mark · compare with the head
+Merge unit/u2: strict Ed25519 verification in fe2o3_crypto, batch agreeing with the single check

commit bced70e

7 days agor1870400018:61142replica 187040001848 operations, since the previous mark · compare with the head
+Merge unit/u1: linkring/1, a scoped linkable ring signature with log-size proofs, in fe2o3_crypto

commit c2deb89 One-out-of-many proof (Bootle et al., radix 16) over ristretto255 with a tag tau = k*H_p(scope) bound to the signing key. 2,945 B at 10^6 keys, 3,489 B at 10^7; verify 3.1 s at 10^6 on one thread, 0.47 s on eight. Tested against an independent Python oracle (vectors and live), with each verify check proven necessary by an attack test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rw9X9iWwNUarckMQx1eS1

7 days agor1870400018:61093replica 187040001814 operations, since the previous mark · compare with the head
+pearlite: desktop launch, open-file dialog, .prl registration, GUI-subsystem binary

commit 5564632 A bare launch shows the native open-file dialog (rfd, gui feature only, xdg-portal backend so no GTK is linked and the glibc-2.39 cross-link holds); a document path with no subcommand opens it directly, as a file association passes it; `open` with no path asks through the same dialog. A desktop-launch failure is shown in a message box as well as on stderr. `pearlite register` associates .prl for the current user: on Linux a shared-mime-info package, a pearlite-reader.desktop entry with a quoted, escaped Exec, cache refreshes (optional) and xdg-mime default (required); on Windows HKCU\Software\Classes through reg.exe, so no unsafe. It is a plan of steps built before anything is touched, then executed. A second binary, pearlite-reader, is linked for the Windows GUI subsystem and does only the desktop launch; register on Windows points the association at it when it sits beside pearlite.exe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HwVhDWVYEX9kDiagwbKzyc

7 days agor1870400018:61078replica 1870400018102 operations, since the previous mark · compare with the head
+Merge tiles/allow-origins-vek: Steel tile config reads (vek|[...]) origins; a no-log vhost keeps connection faults quiet

commit d6ad1fe Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rw9X9iWwNUarckMQx1eS1

7 days agor1870400018:60975replica 187040001814 operations, since the previous mark · compare with the head
+fe2o3_austenite: add S0 speed bench harness (native + wasm, load-gated)

commit a9a9e56 Compares native austenite against typst compile -j1/-j16, and, where Daimond's vendored wasm exists, Austenite wasm against typst.ts wasm in node, on the crate's samples plus a generated synthetic document. Every run is capped (systemd-run, MemoryMax=3G) and timed with /usr/bin/time -v; host load (loadavg + PSI) is read before and after each run and a run made under load is flagged. aggregate.py refuses to report a batch containing a flagged run as a baseline unless forced. austenite --timings (U9) has not landed; speed_bench.sh detects that from source rather than probing the binary, since an unrecognised flag is silently taken as a positional path by austenite's argument parser. Edit-latency times Austenite's compileProjectDelta against typst.ts's real Daimond wiring (a full recompile to `vector`, not incr_compile/ IncrServer, which Daimond does not call anywhere) -- see the README for what that excludes (the browser-only render_svg DOM step). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bi5iALpcJRKNKpw7eQSV64

7 days agor1870400018:60960replica 187040001832 operations, since the previous mark · compare with the head
+fe2o3_austenite: key the oracle harness's qc_dir per checkout

commit db4e325 qc_dir() was one literal path (~/.cache/austenite-qc-rc5) shared by every worktree of this crate. The fleet routinely holds a dozen-plus such worktrees at once, each its own lane, and each one shared this same directory -- so two lanes compiling the same corpus root concurrently wrote and read the SAME <root>-ledger.json and the same document.pdf. This is the harness race that made the m1-ledger-order fix's own verification run read back in plain identity order even after the fix had landed: the binary was right, the shared cache was not. qc_dir() now derives its path from an FNV-1a hash of CARGO_MANIFEST_DIR (the crate's own directory inside its checkout, hashed with the same mixing AnchorId::address uses), under ~/.cache/austenite-qc/<key>, with an AUSTENITE_QC_DIR override for a caller that wants one explicitly. expected.json is untouched -- it is read straight from the crate's own tests/oracle/ tree, never through qc_dir, so it stays the one shared, committed, authoritative pin every checkout reads the same copy of. Only baseline.json (the mutable cache, authoritative solely for a root expected.json does not pin) now lives per checkout; an unpinned root's bootstrap is no longer shared across lanes, the correct side to be wrong on. Also closes the same-checkout half of the race run_austenite's own doc comment already flagged but did not finish: ledger_json_path was keyed by root name alone while out_dir (three lines above it) was already keyed by RC_SLOT and PID. Both are keyed the same way now. Corrects three stale "oxeweb-techspec has no typst oracle" comments (tests/oracle/mod.rs's corpus() doc comment, tests/oracle.rs's module doc comment, and compare_root's F2 comment): re-checked 2026-09-23 against the installed typst 0.15.1, `typst compile` and `typst query` both run clean through the existing patched mirror, so the order-zip now runs for this root -- see the m1-ledger-order commit's own report for the before/after heading-drift numbers this unblocked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bi5iALpcJRKNKpw7eQSV64

8 days agor1870400018:60927replica 187040001824 operations, since the previous mark · compare with the head
+Merge unit/C1: fe2o3_geom map-view helpers (grid level, world detail/merge, labels, tile zoom)

commit c3fb91f Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 days agor1870400018:60902replica 187040001810 operations, since the previous mark · compare with the head
+Merge unit/F1F2 (F3): fe2o3_geom tiles, PMTiles and MVT; HttpRangeSource; Steel serves PMTiles

commit eed7910 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrMRDoZybG2efz6WJV8Y73

8 days agor1870400018:60891replica 1870400018524 operations, since the previous mark · compare with the head
+fe2o3_text: regex with the regex crate's syntax, captures and properties

commit a7e7548 For Typst's regex(): show-regex rules and str.matches/replace/split must find what the regex crate finds. Adds numbered and named captures with byte spans, captures_at/find_at with look-behind context, find_iter and captures_iter with the crate's empty-match rule, split, replace_all and replacen with $n/${name}/$$ expansion; Unicode \d \w \s \b; \p{..}/\P{..} incl. gc!=; nested classes, && -- ~~ and [[:alpha:]]; flags i m s x U, inline, scoped and negated; \A \z \< \> \b{start|end|start-half|end-half}; \x \u \U escapes. ^ and $ are text anchors unless (?m). The recursive AST walk is replaced by a program in the shape of the crate's Thompson NFA, run by a backtracker with a heap stack and an (instruction, position) visited set, as the crate's bounded backtracker is: linear time, no call-stack depth, and the crate's captures where a loop iteration matches nothing. The step and stack budgets go; a search needing more than MAX_VISITED bits is refused with an error instead. tests/regex.rs holds it to three external oracles in tests/regex_oracle: the regex crate's own test suite (716 cases, all agree), Perl over a 151-pattern corpus (every start position, iteration, split, replace), and Perl's Unicode data for ~290k code points, differences accepted only where a UCD 15.0 -> 17.0 file diff lists the change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bi5iALpcJRKNKpw7eQSV64

8 days agor1870400018:60366replica 187040001838 operations, since the previous mark · compare with the head
+fe2o3_austenite: wasm surface catches up to the New Computer Modern Math swap

commit 44d1a85 compile::font_families now seeds from fonts::embedded_families() -- the font lane's own accessor, landed in this unit -- instead of duplicating the embedded family list; EMBEDDED_FAMILIES and wasm.rs's doc comment are corrected from "Latin Modern Math" to "New Computer Modern Math" to match. tests/wasm_api.rs's fc-scan test now reads the actual embedded file (NewCMMath-Regular.otf, latinmodern-math.otf having been removed by this unit's font swap) and compares family names with same_family rather than a literal match, since the file's own declared name ("NewComputerModern Math") differs from Typst's spelling only in whitespace. tests/pdf_fonts.rs's maths oracle test (from m1-pdf-fonts) likewise checked pdffonts' output for the old font's PostScript name; updated to the new one, or every maths PDF fails the embedding check for a font that no longer exists rather than for anything wrong with the embedding. No pinned oracle root's PDF hash moves: none of the 16 crate-owned fixtures compared above use maths, confirmed by rendering each with this branch's binary and diffing the PDF hash against tests/oracle/ expected.json, which m1-pdf-fonts already re-pinned for font embedding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bi5iALpcJRKNKpw7eQSV64

8 days agor1870400018:60327replica 1870400018361 operations, since the previous mark · compare with the head
+Merge unit/S1: fe2o3_steel map tile route with the access log off8 days agor1870400018:59965replica 18704000180 operations, since the previous mark · compare with the head
+Merge unit/S1: fe2o3_steel map tile route with the access log off (superseded by a later mark of this name)

commit 158ba79 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 days agor1870400018:59964replica 187040001843 operations, since the previous mark · compare with the head
+fe2o3_austenite: strict compile reports, fontFamilies, engineInfo for Daimond8 days agor1870400018:59920replica 18704000180 operations, since the previous mark · compare with the head
+fe2o3_austenite: wasm compile reports pages, diagnostics, strict; fontFamilies, engineInfo

commit 5144af6 compileProject (and compileProjectVector, compileProjectDelta) now return {pages, diagnostics, skipped} beside the artefact instead of dropping the refusal table, so a PDF that passed over a construct no longer reads as a clean success. A project with `strict: true` is refused -- {error} in place of the PDF -- on any skipped site, zero pages, or a source setting no content. Failures are {error, diagnostics, skipped}, the error a `file:line:col: message` line: refusals at their real span, an unreadable cited file (a missing #include) at the literal that names it, else 0:0. fontFamilies(project?) lists the embedded families plus each injected `<Family>-<Variant>` font the face resolver loads (one SWITCH-marked call site for the font lane's accessor). engineInfo() gives {engine, version, git}, the commit captured by a new build.rs. The testable core (Report, Diagnostic, locate_error, emit_pdf, font_families, engine_*) lives in compile.rs; tests/wasm_api.rs checks it against pdfinfo, fc-scan and git. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bi5iALpcJRKNKpw7eQSV64

8 days agor1870400018:59919replica 187040001882 operations, since the previous mark · compare with the head
+fe2o3_austenite: fix oracle self-test's stale hardcoded PDF hash8 days agor1870400018:59836replica 18704000180 operations, since the previous mark · compare with the head
+git-47bfc8d

fe2o3_austenite: update the oracle self-test's hardcoded styling-fixture hash

8 days agor1870400018:59835replica 18704000181 operation, since the previous mark · compare with the head
+fe2o3_graphics/fe2o3_font/fe2o3_austenite: embedded subset fonts in PDF export8 days agor1870400018:59833replica 18704000180 operations, since the previous mark · compare with the head
+git-41774d9

fe2o3_austenite: re-pin oracle PDF baselines for embedded subset fonts

8 days agor1870400018:59832replica 187040001888 operations, since the previous mark · compare with the head
+Merge unit/F1F2: fe2o3_geom viewport, Web Mercator, clipped rings, cell cover, world file8 days agor1870400018:59743replica 18704000180 operations, since the previous mark · compare with the head
+git-e7a47ca

Merge unit/F1F2: fe2o3_geom viewport, Web Mercator, clipped rings, cell cover, world file

8 days agor1870400018:59742replica 187040001828 operations, since the previous mark · compare with the head
+fe2o3_net: lean WsClient, read_frame, and handshake checks that count8 days agor1870400018:59713replica 18704000180 operations, since the previous mark · compare with the head
+git-80f4827

Merge unit/P1A: fe2o3_net lean WsClient, read_frame, and handshake checks that count

8 days agor1870400018:59712replica 187040001853 operations, since the previous mark · compare with the head
+fe2o3_austenite: list layout, outline, hierarchy sample, list-fidelity gate (D-20260921-17)8 days agor1870400018:59658replica 18704000180 operations, since the previous mark · compare with the head
+git-c49c131

fe2o3_austenite: render-level list-fidelity gate

8 days agor1870400018:59657replica 187040001871 operations, since the previous mark · compare with the head
+admission control, token-gated health body, distress-aware watcher9 days agor1870400018:59585replica 18704000180 operations, since the previous mark · compare with the head
+git-8423f0f

fe2o3_steel: admission control, token-gated health body, distress watcher

9 days agor1870400018:59584replica 18704000180 operations, since the previous mark · compare with the head
+git-ccd3773

fe2o3_net: per-IP concurrency cap, guard eviction and decay, bounded TLS

9 days agor1870400018:59583replica 18704000180 operations, since the previous mark · compare with the head
+git-bd31a87

fe2o3_core: add ct_eq for constant-time byte comparison

9 days agor1870400018:59582replica 1870400018142 operations, since the previous mark · compare with the head
+fe2o3_steel: swc-family bump (serde 1.0.229 compat) + per-vhost permissions_policy9 days agor1870400018:59439replica 187040001820 operations, since the previous mark · compare with the head
+austenite: render styled-box content-fn text instead of silently dropping it (SEV fix)10 days agor1870400018:59418replica 18704000180 operations, since the previous mark · compare with the head
+git-da480f5

austenite: render styled-box content instead of silently dropping it

10 days agor1870400018:59417replica 187040001824 operations, since the previous mark · compare with the head
+austenite: expand inline mid-prose content-fn calls; evidence-backed oxeweb-techspec self-pin re-baseline10 days agor1870400018:59392replica 18704000180 operations, since the previous mark · compare with the head
+git-f0b7590

austenite: expand inline mid-prose content-fn calls

10 days agor1870400018:59391replica 187040001834 operations, since the previous mark · compare with the head
+austenite: strong #pagebreak adds a trailing page; substitute a standalone-line scalar10 days agor1870400018:59356replica 18704000180 operations, since the previous mark · compare with the head
+git-bac7751

austenite: strong #pagebreak adds a trailing page; substitute a standalone-line scalar

10 days agor1870400018:59355replica 187040001834 operations, since the previous mark · compare with the head
+austenite: resolve term-dict keys after parameter substitution in content-fn bodies10 days agor1870400018:59320replica 18704000180 operations, since the previous mark · compare with the head
+git-deccd46

austenite: resolve term-dict keys after parameter substitution in content-fn bodies

10 days agor1870400018:59319replica 18704000189 operations, since the previous mark · compare with the head
+austenite: honour own-line #pagebreak/#v beneath prose (render-fix)10 days agor1870400018:59309replica 18704000180 operations, since the previous mark · compare with the head
+git-37275db

Merge builtin render-fix: own-line #pagebreak/#v beneath prose are honoured

10 days agor1870400018:59308replica 18704000189 operations, since the previous mark · compare with the head
+pearlite: native windowed reader (winit+softbuffer gui) + PageSink refactor10 days agor1870400018:59298replica 18704000180 operations, since the previous mark · compare with the head
+git-daa62f6

Merge native Pearlite reader (winit+softbuffer gui, PageSink refactor)

10 days agor1870400018:59297replica 187040001863 operations, since the previous mark · compare with the head
+austenite: substitute scalar #let value bindings10 days agor1870400018:59233replica 18704000180 operations, since the previous mark · compare with the head
+git-415a0f8

austenite: substitute scalar #let value bindings

10 days agor1870400018:59232replica 187040001856 operations, since the previous mark · compare with the head
+austenite: render #pagebreak/#lorem/#v markup builtins on the block path10 days agor1870400018:59175replica 18704000180 operations, since the previous mark · compare with the head
+git-5b2fe9e

austenite: markup builtins own-line only; refuse trailing/mid-prose and ignored args

10 days agor1870400018:59174replica 187040001824 operations, since the previous mark · compare with the head
+pearlite+ore: harden the collaboration signing core against a malicious peer10 days agor1870400018:59149replica 18704000180 operations, since the previous mark · compare with the head
+git-bf9bdba

pearlite+ore: harden the collaboration signing core against a malicious peer

10 days agor1870400018:59148replica 1870400018108 operations, since the previous mark · compare with the head
+austenite: evaluate #let content bindings and resolve #import on all paths10 days agor1870400018:59039replica 18704000180 operations, since the previous mark · compare with the head
+git-d6c460a

austenite: evaluate #let content bindings and resolve #import on all paths

10 days agor1870400018:59038replica 1870400018118 operations, since the previous mark · compare with the head
+fe2o3_net: std-only loopback server + optional async/TLS; drop python http.server10 days agor1870400018:58919replica 18704000180 operations, since the previous mark · compare with the head
+git-46e5cba

fe2o3_net: std-only loopback static server + optional async/TLS; drop python http.server

10 days agor1870400018:58918replica 187040001883 operations, since the previous mark · compare with the head
+austenite: wire the incremental memo into compileProjectDelta (26x warm recompile)10 days agor1870400018:58834replica 18704000180 operations, since the previous mark · compare with the head
+git-17e1b6d

austenite: wire the incremental memo into compileProjectDelta (the swap payoff)

10 days agor1870400018:58833replica 187040001842 operations, since the previous mark · compare with the head
+austenite: Austenite-wasm changed-only page delta (wasm swap increment)10 days agor1870400018:58790replica 18704000180 operations, since the previous mark · compare with the head
+git-d9778e4

austenite: Austenite-wasm changed-only page delta (wasm swap increment)

10 days agor1870400018:58789replica 187040001817 operations, since the previous mark · compare with the head
+austenite: main index reference folio in bold (idx-main)10 days agor1870400018:58771replica 18704000180 operations, since the previous mark · compare with the head
+git-7e955f3

austenite: set a main index reference's folio in bold (idx-main)

10 days agor1870400018:58770replica 18704000182 operations, since the previous mark · compare with the head
+git-d026ae6

austenite: set a main index reference's folio in bold (idx-main)

10 days agor1870400018:58767replica 187040001863 operations, since the previous mark · compare with the head
+austenite: prove memo byte-correct under pagination-shifting warm edit10 days agor1870400018:58703replica 18704000180 operations, since the previous mark · compare with the head
+git-3970309

austenite: prove the memo is byte-correct under a pagination-shifting warm edit

10 days agor1870400018:58702replica 187040001818 operations, since the previous mark · compare with the head
+austenite: incremental content-hash memo (A7 increment 1) -- block + page memo, ~19x warm recompile, no-op on production paths10 days agor1870400018:58683replica 18704000180 operations, since the previous mark · compare with the head
+git-a2b85be

austenite: incremental content-hash memo (A7 increment 1 — block + page memo, native)

10 days agor1870400018:58682replica 187040001861 operations, since the previous mark · compare with the head
+pearlite: collaboration backend increment 1 -- format-v1 doc_id header + Ore signed-op sync + o3db hub + deterministic fold10 days agor1870400018:58620replica 18704000180 operations, since the previous mark · compare with the head
+git-7a53ffe

pearlite: collaboration backend — format-v1 doc_id + Ore signed-op sync + o3db hub + fold (increment 1)

10 days agor1870400018:58619replica 187040001823 operations, since the previous mark · compare with the head
+austenite: claim-index in-flow placement + body leading (DL4), corporate authors + year suffixes (DL2), index italics/comma/display (DL5)10 days agor1870400018:58595replica 18704000180 operations, since the previous mark · compare with the head
+git-9128d5a

austenite: set index entries by display text, italic markup and a folio comma

10 days agor1870400018:58594replica 187040001886 operations, since the previous mark · compare with the head
+austenite: -- to en dash, --- to em dash, ... to ellipsis in markup (Typst smartypants); 3 roots re-pinned glyph-only10 days agor1870400018:58507replica 18704000180 operations, since the previous mark · compare with the head
+git-f368c75

austenite: convert -- to en dash and --- to em dash in markup (Typst smartypants)

10 days agor1870400018:58506replica 18704000185 operations, since the previous mark · compare with the head
+austenite: render #cite + gather #index/#claim inside table cells (silent-loss root fix via shared build_pieces)10 days agor1870400018:58500replica 18704000180 operations, since the previous mark · compare with the head
+git-171adf2

austenite: render #cite and gather #index/#claim inside table cells (silent-loss fix)

10 days agor1870400018:58499replica 187040001877 operations, since the previous mark · compare with the head
+austenite: bound skip-scanner // to line end + Frame::Raw for backtick code spans (G6 latent-leak hardening)10 days agor1870400018:58421replica 18704000180 operations, since the previous mark · compare with the head
+git-eb37a74

austenite: bound skip-scanner // to line end + Frame::Raw for backtick spans (G6)

10 days agor1870400018:58420replica 187040001810 operations, since the previous mark · compare with the head
+austenite: reverse claim-reference index (ledger-built, -9pp) + bibliography density (-4pp); out-of-body claim-ref refusal11 days agor1870400018:58409replica 18704000180 operations, since the previous mark · compare with the head
+git-9eab20a

austenite: milestone-audit fixes for the back-matter units

11 days agor1870400018:58408replica 187040001894 operations, since the previous mark · compare with the head
+austenite: glossary continuation header + cell cap-edge (12pp=Typst); multi-column foot guard, loud-refuse, x-band gate11 days agor1870400018:58313replica 18704000180 operations, since the previous mark · compare with the head
+git-60782d7

austenite: flow_columns foot guard, columns loud refusal, index x-band gate

11 days agor1870400018:58312replica 187040001839 operations, since the previous mark · compare with the head
+austenite: construct silent-loss hardening (bracket-aware #if guards, comment-aware skip scanner, G1-G4)11 days agor1870400018:58272replica 18704000180 operations, since the previous mark · compare with the head
+git-61c729e

austenite: fix stale media-bracket-fixture comment

11 days agor1870400018:58271replica 187040001848 operations, since the previous mark · compare with the head
+pearlite: native .prl reader crate (raster + loopback browser-shell, Phases 1-2)11 days agor1870400018:58222replica 18704000180 operations, since the previous mark · compare with the head
+git-dedd623

pearlite: native .prl reader crate -- raster + loopback browser-shell app (Phases 1-2)

11 days agor1870400018:58221replica 187040001811 operations, since the previous mark · compare with the head
+austenite: generic multi-column body flow; index renders two-column11 days agor1870400018:58209replica 18704000180 operations, since the previous mark · compare with the head
+git-7c0a7bf

austenite: generic multi-column body flow; index renders two-column

11 days agor1870400018:58208replica 187040001825 operations, since the previous mark · compare with the head
+austenite: Pearl web-reader colour fix + .prl/.tsel oracle round-trip gate + sample regen11 days agor1870400018:58182replica 18704000180 operations, since the previous mark · compare with the head
+git-82d5903

austenite: web reader honours leaf colour, .prl gated against real SVG output

11 days agor1870400018:58181replica 187040001833 operations, since the previous mark · compare with the head
+austenite: refuse #context{} brace form + honour #if media include guards + 2 gate fixtures11 days agor1870400018:58147replica 18704000180 operations, since the previous mark · compare with the head
+git-9f17000

austenite: replace ? with house error macros in the construct-handling helpers

11 days agor1870400018:58146replica 187040001833 operations, since the previous mark · compare with the head
+austenite: follow chapter-level cross-dir #include (fixes silent drop of ch18 evidence + techspec sec_dilemma) + gate11 days agor1870400018:58112replica 18704000180 operations, since the previous mark · compare with the head
+git-98e8fdd

austenite: rebase include-fix onto main, re-pin techspec for sec_dilemma, tag include-refusal file

11 days agor1870400018:58111replica 187040001820 operations, since the previous mark · compare with the head
+austenite: flow breakable glossary rows across pages + bib 0.85em + glossary-oracle gate11 days agor1870400018:58090replica 18704000180 operations, since the previous mark · compare with the head
+git-ad4f8d9

austenite: flow breakable glossary rows across pages; bibliography at 0.85em; add glossary oracle root

11 days agor1870400018:58089replica 18704000181925 operations, since the previous mark · compare with the head
+Merge dilithium mode-gate: a no-mode fe2o3_crypto build compiles11 days agor1870400018:56163replica 18704000180 operations, since the previous mark · compare with the head
+git-da29634

Merge dilithium mode-gate: a no-mode fe2o3_crypto build compiles

11 days agor1870400018:56162replica 18704000183868 operations, since the previous mark · compare with the head
+austenite: B1 edge model + A19 doc-grid opener + float-overlap/anchor-region fix + back-matter11 days agor1870400018:52293replica 18704000180 operations, since the previous mark · compare with the head
+git-1679250

austenite: complete anchor-region membership for float relayout (nested anchors)

11 days agor1870400018:52292replica 1870400018121 operations, since the previous mark · compare with the head
+Merge P-256 verify: wasm-clean verify-only ECDSA behind the p256 feature11 days agor1870400018:52170replica 18704000180 operations, since the previous mark · compare with the head
+git-bbc59ec

gitignore: exclude .wt worktrees, so a git add or ore mark never captures a checkout

11 days agor1870400018:52169replica 18704000181916 operations, since the previous mark · compare with the head
+git-39c2a08

Merge P-256 verify: wasm-clean verify-only ECDSA behind the p256 feature

11 days agor1870400018:50252replica 187040001837 operations, since the previous mark · compare with the head
+austenite: generate the glossary and index back matter11 days agor1870400018:50214replica 18704000180 operations, since the previous mark · compare with the head
+git-bca212b

austenite: generate the glossary and index back matter

11 days agor1870400018:50213replica 1870400018128 operations, since the previous mark · compare with the head
+austenite: per-slot oracle render out-dir (fix concurrent false-red harness race)11 days agor1870400018:50084replica 18704000180 operations, since the previous mark · compare with the head
+git-ad6f5e2

austenite: key the oracle render dir by RC_SLOT and PID to stop lane races

11 days agor1870400018:50083replica 18704000183825 operations, since the previous mark · compare with the head
+austenite: lower em-valued #set par spacing on the set-path (A11)11 days agor1870400018:46257replica 18704000180 operations, since the previous mark · compare with the head
+git-641084e

austenite: honour em paragraph spacing in the #set par lowering path

11 days agor1870400018:46256replica 187040001816 operations, since the previous mark · compare with the head
+austenite: shared compile pipeline for bin + wasm (D1b de-drift)11 days agor1870400018:46239replica 18704000180 operations, since the previous mark · compare with the head
+git-1fdee15

austenite: lift the shared compile pipeline into one module

11 days agor1870400018:46238replica 187040001822 operations, since the previous mark · compare with the head
+austenite: Typst float-placement engine + parity fixture + opener running-head fix11 days agor1870400018:46215replica 18704000180 operations, since the previous mark · compare with the head
+git-6918e9d

austenite: upgrade float_fixture to a true Typst-parity gate

11 days agor1870400018:46214replica 1870400018101 operations, since the previous mark · compare with the head
+austenite: Typst float-placement engine + parity fixture + opener running-head fix (superseded by a later mark of this name)11 days agor1870400018:46112replica 18704000180 operations, since the previous mark · compare with the head
+geom: add cell cube-sphere quadtree global grid (H3 replacement)11 days agor1870400018:46111replica 18704000181905 operations, since the previous mark · compare with the head
+git-a5ae623

Merge fe2o3_geom::cell: cube-sphere quadtree global grid (H3 replacement)

11 days agor1870400018:44205replica 18704000183823 operations, since the previous mark · compare with the head
+austenite: selectable-text SVG layer + Pearl v1 self-describing text-leaf11 days agor1870400018:40381replica 18704000180 operations, since the previous mark · compare with the head
+git-245cc4c

austenite: Pearl v1 -- a self-describing text-leaf tail, root-fixing the v0 split-brain

11 days agor1870400018:40380replica 187040001859 operations, since the previous mark · compare with the head
+austenite: reader backlog fixes + wasm target via in-memory VFS12 days agor1870400018:40320replica 18704000180 operations, since the previous mark · compare with the head
+git-c047a9c

Merge wt-wasm (D1): Austenite wasm target via in-memory VFS

12 days agor1870400018:40319replica 187040001848 operations, since the previous mark · compare with the head
+git-bdbd406

austenite: #sub[...] subscript, and evaluate table row-remap .map() spreads

12 days agor1870400018:40270replica 187040001838 operations, since the previous mark · compare with the head
+austenite: render the #strong[...] / #strong(...) call form as bold (mirrors #emph)12 days agor1870400018:40231replica 18704000180 operations, since the previous mark · compare with the head
+git-2177386

austenite: render #strong[...] and #strong("...") as bold

12 days agor1870400018:40230replica 18704000184 operations, since the previous mark · compare with the head
+austenite: #aside-box via general #let templates -- palette resolution, import-chain walk, stroke/title, par-spacing (A1 milestone 2; closes the Lucronics reproduction)12 days agor1870400018:40225replica 18704000180 operations, since the previous mark · compare with the head
+git-7b45744

austenite: #let furniture milestone 2 -- #aside-box, palette, stroke, spacing

12 days agor1870400018:40224replica 18704000180 operations, since the previous mark · compare with the head
+git-7b45744 (superseded by a later mark of this name)

austenite: #let furniture milestone 2 -- #aside-box, palette, stroke, spacing

12 days agor1870400018:40223replica 187040001845 operations, since the previous mark · compare with the head
+austenite: general #let template-function evaluation -- #pr-note expands to a box instead of dropping (A1, milestone 1)12 days agor1870400018:40177replica 18704000180 operations, since the previous mark · compare with the head
+git-2a59729

austenite: general #let template-function expansion (milestone 1: #pr-note)

12 days agor1870400018:40176replica 18704000180 operations, since the previous mark · compare with the head
+git-2a59729 (superseded by a later mark of this name)

austenite: general #let template-function expansion (milestone 1: #pr-note)

12 days agor1870400018:40175replica 187040001852 operations, since the previous mark · compare with the head
+austenite: consume a template's block.with(inset:, radius:) instead of dropping them (consume-or-refuse fix)12 days agor1870400018:40122replica 18704000180 operations, since the previous mark · compare with the head
+git-2cd25bc

austenite: consume a template's block.with(inset:, radius:) instead of dropping them

12 days agor1870400018:40121replica 187040001834 operations, since the previous mark · compare with the head
+austenite: A1 marginalia -- outside-margin claim codes drawn post-convergence from the ledger (closes the Lucronics reproduction gap)12 days agor1870400018:40086replica 18704000180 operations, since the previous mark · compare with the head
+git-16c401b

austenite: A1 MARGINALIA -- outside-margin claim codes drawn post-convergence

12 days agor1870400018:40085replica 18704000180 operations, since the previous mark · compare with the head
+git-16c401b (superseded by a later mark of this name)

austenite: A1 MARGINALIA -- outside-margin claim codes drawn post-convergence

12 days agor1870400018:40084replica 187040001866 operations, since the previous mark · compare with the head
+austenite: A1 C2 -- thread #set text(fill:) through to the emitters (prose text colour)12 days agor1870400018:40017replica 18704000180 operations, since the previous mark · compare with the head
+git-71b3b01

austenite: thread a prose text fill colour through the engine

12 days agor1870400018:40016replica 187040001844 operations, since the previous mark · compare with the head
+austenite: resolve block-scoped heading faces on the lone-file path (A1 finding 4)12 days agor1870400018:39971replica 18704000180 operations, since the previous mark · compare with the head
+git-d92e96c

austenite: resolve block-scoped heading faces on the lone-file path (A1 finding 4)

12 days agor1870400018:39970replica 187040001811 operations, since the previous mark · compare with the head
+austenite: A1 Part 2 -- Transform::Template (template-with-holes + wrap show rules, frame/space/rule siblings, heading spacing guard, avail threading)12 days agor1870400018:39958replica 18704000180 operations, since the previous mark · compare with the head
+git-8ed9cf2

austenite: Transform::Template -- template-with-holes + wrap show rules (A1 Part 2)

12 days agor1870400018:39957replica 18704000180 operations, since the previous mark · compare with the head
+git-8ed9cf2 (superseded by a later mark of this name)

austenite: Transform::Template -- template-with-holes + wrap show rules (A1 Part 2)

12 days agor1870400018:39956replica 187040001836 operations, since the previous mark · compare with the head
+austenite: A1 Part 2 prerequisites -- reader no longer double-reports selector rules; par rule keeps with heading through its scope12 days agor1870400018:39919replica 18704000180 operations, since the previous mark · compare with the head
+git-8b87cf4

austenite: scope-transparent keep-with-next for a par rule (A1 finding 5)

12 days agor1870400018:39918replica 18704000180 operations, since the previous mark · compare with the head
+git-8b87cf4 (superseded by a later mark of this name)

austenite: scope-transparent keep-with-next for a par rule (A1 finding 5)

12 days agor1870400018:39917replica 18704000181 operation, since the previous mark · compare with the head
+git-b0a5357

austenite: stop the reader double-reporting an authored show rule (A1 finding 3)

12 days agor1870400018:39915replica 187040001815 operations, since the previous mark · compare with the head
+austenite: A1 styling rule engine MVP -- selector + set-fields transform, scope-aware walks, heading text-size consume12 days agor1870400018:39899replica 18704000180 operations, since the previous mark · compare with the head
+git-9486a4c

oracle: re-pin oxeweb roots after source drift

12 days agor1870400018:39898replica 18704000180 operations, since the previous mark · compare with the head
+git-9486a4c (superseded by a later mark of this name)

oracle: re-pin oxeweb roots after source drift

12 days agor1870400018:39897replica 18704000184 operations, since the previous mark · compare with the head
+git-5477d01

austenite: thread keep-with-next through a nested scope edge (A1 finding 1)

12 days agor1870400018:39892replica 18704000182 operations, since the previous mark · compare with the head
+git-fac922c

austenite: a heading text-size rule resizes the heading (A1 finding 1)

12 days agor1870400018:39889replica 187040001820 operations, since the previous mark · compare with the head
+git-0b1c802

austenite: styling rule engine -- selector + set-fields transform (A1 Part 1)

12 days agor1870400018:39868replica 18704000187 operations, since the previous mark · compare with the head
+git-517a99d

austenite: make the document-order walks scope-aware (A1 Part 0)

12 days agor1870400018:39860replica 187040001825 operations, since the previous mark · compare with the head
+austenite: harden A1 Theme layer — nesting Block::Scoped, consumed-or-refused #sets, resolver union+weight axis, honest group_dat12 days agor1870400018:39834replica 18704000180 operations, since the previous mark · compare with the head
+git-9da4eff

austenite: align group_dat read-set with the renderer (A1 concern 4)

12 days agor1870400018:39833replica 18704000180 operations, since the previous mark · compare with the head
+git-9da4eff (superseded by a later mark of this name)

austenite: align group_dat read-set with the renderer (A1 concern 4)

12 days agor1870400018:39832replica 187040001823 operations, since the previous mark · compare with the head
+git-1c11046

austenite: face resolver reaches every face a rule could name (A1 concern 3)

12 days agor1870400018:39808replica 187040001829 operations, since the previous mark · compare with the head
+git-da1accb

austenite: no #set silently no-ops -- consume or refuse (A1 concern 2)

12 days agor1870400018:39778replica 187040001867 operations, since the previous mark · compare with the head
+git-b0ab16a

austenite: nesting-aware scopes with a proven Block::Scoped (A1 concern 1)

12 days agor1870400018:39710replica 187040001847 operations, since the previous mark · compare with the head
+austenite: A1 Theme made real — ThemePatch + scoped-theme machinery, reshape (Vec levels, group_dat/bdat), face resolver (Graystroke), gate pinned in-tree12 days agor1870400018:39662replica 18704000180 operations, since the previous mark · compare with the head
+git-4978ede

austenite: green the oracle gate -- drop austenite-doc for now, make expected.json authoritative

12 days agor1870400018:39661replica 18704000180 operations, since the previous mark · compare with the head
+git-4978ede (superseded by a later mark of this name)

austenite: green the oracle gate -- drop austenite-doc for now, make expected.json authoritative

12 days agor1870400018:39660replica 18704000188 operations, since the previous mark · compare with the head
+git-e869a91

austenite: pin the oracle reference in-tree so a fresh box cannot self-bless a regression

12 days agor1870400018:39651replica 18704000187 operations, since the previous mark · compare with the head
+git-33bbe84

austenite: make the renderer consume the theme's heading face -- resolve and set it (toward Typst)

12 days agor1870400018:39643replica 187040001862 operations, since the previous mark · compare with the head
+git-e4c0925

austenite: reshape the Theme for the rule engine -- Vec levels, an opener group, page classes, group_dat

12 days agor1870400018:39580replica 187040001874 operations, since the previous mark · compare with the head
+git-7f886bb

austenite: refuse a #set that lowers to nothing rather than drop it silently (H2)

12 days agor1870400018:39505replica 187040001822 operations, since the previous mark · compare with the head
+git-6bf5feb

austenite: scope a subtree's #set to that subtree via theme-scope markers (H1)

12 days agor1870400018:39482replica 187040001813 operations, since the previous mark · compare with the head
+git-c7913ac

austenite: introduce ThemePatch and lower declarations to a patch, not a mutated Theme

12 days agor1870400018:39468replica 187040001889 operations, since the previous mark · compare with the head
+austenite: harden the oracle gate — asserted PDF hash + accept-switch, oxeweb-overview Typst-comparable, styling fixture, raster assertion12 days agor1870400018:39378replica 18704000180 operations, since the previous mark · compare with the head
+git-0567b0a

austenite: harden the oracle harness -- assert PDF hash, oxeweb-vs-Typst, styling fixture, raster

12 days agor1870400018:39377replica 18704000180 operations, since the previous mark · compare with the head
+git-0567b0a (superseded by a later mark of this name)

austenite: harden the oracle harness -- assert PDF hash, oxeweb-vs-Typst, styling fixture, raster

12 days agor1870400018:39376replica 187040001897 operations, since the previous mark · compare with the head
+austenite: A1 Theme — grouped serialisable Theme (pure refactor) + lower #show:doc.with/top-level #set12 days agor1870400018:39278replica 18704000180 operations, since the previous mark · compare with the head
+git-3895467

austenite: lower a document's #show: doc.with and top-level #set onto the Theme

12 days agor1870400018:39277replica 18704000180 operations, since the previous mark · compare with the head
+git-3895467 (superseded by a later mark of this name)

austenite: lower a document's #show: doc.with and top-level #set onto the Theme

12 days agor1870400018:39276replica 187040001812 operations, since the previous mark · compare with the head
+git-003efa2

austenite: replace the flat Style struct with a grouped, serialisable Theme

12 days agor1870400018:39263replica 1870400018244 operations, since the previous mark · compare with the head
+austenite: A1 foundation — oracle harness + per-site refusal diagnostic12 days agor1870400018:39018replica 18704000180 operations, since the previous mark · compare with the head
+git-6c978bc

austenite: upgrade the reader's skip tally into a per-site refusal diagnostic

12 days agor1870400018:39017replica 18704000180 operations, since the previous mark · compare with the head
+git-6c978bc (superseded by a later mark of this name)

austenite: upgrade the reader's skip tally into a per-site refusal diagnostic

12 days agor1870400018:39016replica 1870400018142 operations, since the previous mark · compare with the head
+git-8e35c7a

austenite: add an oracle harness that reproduces Typst against a bounded corpus

12 days agor1870400018:38873replica 187040001830 operations, since the previous mark · compare with the head
+austenite: content-mode bracket scanner + TechSpec maths symbols (circled ops, blackboard, ceil/floor, binom, display) — display-equation task complete12 days agor1870400018:38842replica 18704000180 operations, since the previous mark · compare with the head
+git-59c6e3b

austenite: add the maths symbols and layouts the TechSpec uses -- circled ops, blackboard sets, ceil/floor, binom, display

12 days agor1870400018:38841replica 18704000180 operations, since the previous mark · compare with the head
+git-59c6e3b (superseded by a later mark of this name)

austenite: add the maths symbols and layouts the TechSpec uses -- circled ops, blackboard sets, ceil/floor, binom, display

12 days agor1870400018:38840replica 187040001816 operations, since the previous mark · compare with the head
+git-8893ff6

austenite: make the bracket scanner content-mode aware so captions with prose parens close

13 days agor1870400018:38823replica 187040001822 operations, since the previous mark · compare with the head
+austenite: keep an open display-maths block from being stolen line by line13 days agor1870400018:38800replica 18704000180 operations, since the previous mark · compare with the head
+git-87ca15c

austenite: keep an open display-maths block from being stolen line by line

13 days agor1870400018:38799replica 18704000180 operations, since the previous mark · compare with the head
+git-87ca15c (superseded by a later mark of this name)

austenite: keep an open display-maths block from being stolen line by line

13 days agor1870400018:38798replica 18704000187 operations, since the previous mark · compare with the head
+graphics/austenite: compact PDFs — compress + round, then Type-3 glyph fonts with ToUnicode (62MB→636KB Overview, 2.67MB TechSpec)13 days agor1870400018:38790replica 18704000180 operations, since the previous mark · compare with the head
+git-b3ab618

graphics/austenite: store glyphs as Type-3 fonts with a ToUnicode CMap

13 days agor1870400018:38789replica 18704000180 operations, since the previous mark · compare with the head
+git-b3ab618 (superseded by a later mark of this name)

graphics/austenite: store glyphs as Type-3 fonts with a ToUnicode CMap

13 days agor1870400018:38788replica 187040001855 operations, since the previous mark · compare with the head
+git-d088ac1

graphics/austenite: compress PDF content streams and round coordinates

13 days agor1870400018:38732replica 18704000188 operations, since the previous mark · compare with the head
+austenite: Oxegen doc parity — glossary, styled-box, nested lists, title wrap13 days agor1870400018:38723replica 18704000180 operations, since the previous mark · compare with the head
+git-9ad0b85

austenite: wrap the doc title page's title to the rail width

13 days agor1870400018:38722replica 18704000180 operations, since the previous mark · compare with the head
+git-9ad0b85 (superseded by a later mark of this name)

austenite: wrap the doc title page's title to the rail width

13 days agor1870400018:38721replica 18704000188 operations, since the previous mark · compare with the head
+git-80f4558

austenite: nest indented sub-lists so a numbered flow keeps counting

13 days agor1870400018:38712replica 187040001842 operations, since the previous mark · compare with the head
+git-b85b121

austenite: render #styled-box[...] callouts instead of dropping the body

13 days agor1870400018:38669replica 187040001814 operations, since the previous mark · compare with the head
+git-68227b7

austenite: render #print-glossary() as a Term/Definition table

13 days agor1870400018:38654replica 187040001824 operations, since the previous mark · compare with the head
+Merge lane/pearl-v0: Pearl (.prl) format, Rust + web readers, annotations13 days agor1870400018:38629replica 18704000180 operations, since the previous mark · compare with the head
+git-a85ebb9

Merge lane/pearl-v0: Pearl (.prl) format, Rust + web readers, annotations

13 days agor1870400018:38628replica 18704000180 operations, since the previous mark · compare with the head
+austenite: in-browser annotation authoring -- create, save to .prl, Rust reader agrees13 days agor1870400018:38627replica 18704000180 operations, since the previous mark · compare with the head
+git-0da2ae1

Pearl web reader: in-browser annotation authoring

13 days agor1870400018:38626replica 187040001819 operations, since the previous mark · compare with the head
+austenite: Pearl web reader is usable -- open-file, clickable links, rendered annotations13 days agor1870400018:38606replica 18704000180 operations, since the previous mark · compare with the head
+git-a97dd0c

Pearl web reader: open-your-own-file, clickable links, rendered annotations

13 days agor1870400018:38605replica 187040001821 operations, since the previous mark · compare with the head
+austenite: merge Pearl links + annotation anchor model13 days agor1870400018:38583replica 18704000180 operations, since the previous mark · compare with the head
+git-66bfcc2

Merge lane/pearl-annots: links + annotation anchor model in Pearl

13 days agor1870400018:38582replica 187040001834 operations, since the previous mark · compare with the head
+austenite: Pearl web reader parses the real .prl in the browser13 days agor1870400018:38547replica 18704000180 operations, since the previous mark · compare with the head
+git-b377e42

austenite: Pearl web reader parses the real .prl in the browser

13 days agor1870400018:38546replica 187040001853 operations, since the previous mark · compare with the head
+pearl web reader first cut13 days agor1870400018:38492replica 18704000180 operations, since the previous mark · compare with the head
+git-ef14312

austenite: Pearl web reader first cut + pearl_json transport

13 days agor1870400018:38491replica 187040001817 operations, since the previous mark · compare with the head
+ore: exercise forget against a move, a cross-boundary cut and an overlap13 days agor1870400018:38473replica 18704000180 operations, since the previous mark · compare with the head
+git-42b08c8

ore: exercise forget against a move, a cross-boundary cut and an overlap

13 days agor1870400018:38472replica 18704000181 operation, since the previous mark · compare with the head
+austenite: a Pearl v0 keystone -- one document round-tripping to SVG parity13 days agor1870400018:38470replica 18704000180 operations, since the previous mark · compare with the head
+git-b4a1db8

austenite: a Pearl v0 keystone -- one document round-tripping to SVG parity

13 days agor1870400018:38469replica 187040001828 operations, since the previous mark · compare with the head
+Record that fe2o3_austenite/dev is executable15 days agor1870400018:38440replica 18704000181 operation, since the previous mark · compare with the head
+git-4a7e31a

jdat: say which way min_size_int narrows, and pin its boundaries

15 days agor1870400018:38438replica 18704000180 operations, since the previous mark · compare with the head
+rc6-p5: first lane bridge commit15 days agor2975867427:38437replica 29758674272 operations, since the previous mark · compare with the head
+Grow a stalled sketch, and tell a carrier where it left off15 days agor1870400018:38434replica 18704000180 operations, since the previous mark · compare with the head
+git-a6a5016

Grow a stalled sketch, and tell a carrier where it left off

15 days agor1870400018:38433replica 187040001892 operations, since the previous mark · compare with the head
+ore: let a peer be told what to write where a forgotten operation stood15 days agor1870400018:38340replica 18704000180 operations, since the previous mark · compare with the head
+git-4004353

ore: let a peer be told what to write where a forgotten operation stood

15 days agor1870400018:38339replica 187040001863 operations, since the previous mark · compare with the head
+git-a07fea5

ore: let an operation be forgotten, keeping its shape and losing its content

15 days agor1870400018:38275replica 187040001881 operations, since the previous mark · compare with the head
+text: name the files a secret lives in, beside the scanner that reads its shape15 days agor1870400018:38193replica 18704000180 operations, since the previous mark · compare with the head
+git-367ca5e

text: name the files a secret lives in, beside the scanner that reads its shape

15 days agor1870400018:38192replica 18704000185 operations, since the previous mark · compare with the head
+sbj: a share may not carry armed automation or the sender's own machine15 days agor1870400018:38186replica 18704000180 operations, since the previous mark · compare with the head
+git-ccf9c2c

sbj: a share may not carry armed automation or the sender's own machine

15 days agor1870400018:38185replica 187040001812 operations, since the previous mark · compare with the head
+o3db: close the residual GC read races -- pin postgc reads, retry with a fresh location18 days agor1870400018:38172replica 18704000180 operations, since the previous mark · compare with the head
+git-5ffee6a

o3db: close the residual GC read races -- pin postgc reads, retry with a fresh location

18 days agor1870400018:38171replica 18704000187 operations, since the previous mark · compare with the head
+o3db: invalidate reader file handles on GC rename, and always release the reader count19 days agor1870400018:38163replica 18704000180 operations, since the previous mark · compare with the head
+git-94beb46

o3db: invalidate reader file handles on GC rename, and always release the reader count

19 days agor1870400018:38162replica 187040001811 operations, since the previous mark · compare with the head
+o3db: online orphan sweep, and fix a latent deleted-chunked-value read20 days agor1870400018:38150replica 18704000180 operations, since the previous mark · compare with the head
+git-d8cb2e4

o3db: online orphan sweep, and fix a latent deleted-chunked-value read

20 days agor1870400018:38149replica 187040001820 operations, since the previous mark · compare with the head
+jdat: bound the BDAT streaming loader against a torn or hostile length22 days agor1870400018:38128replica 18704000180 operations, since the previous mark · compare with the head
+git-3fcbe43

jdat: bound the BDAT streaming loader against a torn or hostile length

22 days agor1870400018:38127replica 18704000188 operations, since the previous mark · compare with the head
+o3db: live-set migration tool to compact a store and drop orphaned chunks22 days agor1870400018:38118replica 18704000180 operations, since the previous mark · compare with the head
+git-14d3bf0

o3db: live-set migration tool to compact a store and drop orphaned chunks

22 days agor1870400018:38117replica 18704000187 operations, since the previous mark · compare with the head
+o3db: reclaim superseded chunks via deterministic keys, fix chunk-record GC accounting22 days agor1870400018:38109replica 18704000180 operations, since the previous mark · compare with the head
+git-4d97035

o3db: reclaim superseded chunks via deterministic keys, fix chunk-record GC accounting

22 days agor1870400018:38108replica 187040001826 operations, since the previous mark · compare with the head
+o3db: durability barriers on seal and GC, torn-tail-tolerant rebuild, group-commit default22 days agor1870400018:38081replica 18704000180 operations, since the previous mark · compare with the head
+git-b97fd97

o3db: durability barriers on seal and GC, torn-tail-tolerant rebuild, group-commit default

22 days agor1870400018:38080replica 187040001828 operations, since the previous mark · compare with the head
+net: add vision (image) support to the OpenAI-compatible llm client23 days agor1870400018:38051replica 18704000180 operations, since the previous mark · compare with the head
+git-6589b4d

net: add vision (image) support to the OpenAI-compatible llm client

23 days agor1870400018:38050replica 18704000183 operations, since the previous mark · compare with the head
+austenite: an explicit #section-banner owns its chapter header in a DocBanner doc23 days agor1870400018:38046replica 18704000180 operations, since the previous mark · compare with the head
+git-4da55d0

austenite: an explicit #section-banner owns its chapter's header in a DocBanner doc

23 days agor1870400018:38045replica 18704000183 operations, since the previous mark · compare with the head
+austenite: clickable Made-with-AI chip, clean inline-idiom front matter, Austenite colophon24 days agor1870400018:38041replica 18704000180 operations, since the previous mark · compare with the head
+git-e4fffe4

austenite: colophon says Austenite, not Typst

24 days agor1870400018:38040replica 18704000182 operations, since the previous mark · compare with the head
+git-a019d9d

austenite: fix stray folio and footer logo on inline-idiom front matter

24 days agor1870400018:38037replica 187040001843 operations, since the previous mark · compare with the head
+austenite: render the doc META/colophon page (Title/Meta/Contents in the outline)24 days agor1870400018:37993replica 18704000180 operations, since the previous mark · compare with the head
+git-7d48971

austenite: render the doc META/colophon page

24 days agor1870400018:37992replica 187040001832 operations, since the previous mark · compare with the head
+austenite: PDF document outline (front matter first) + doc #section-banner logos24 days agor1870400018:37959replica 18704000180 operations, since the previous mark · compare with the head
+git-11a18b9

Merge branch 'lane/austenite-sectionbanner'

24 days agor1870400018:37958replica 187040001837 operations, since the previous mark · compare with the head
+git-e3d67fe

austenite: emit a PDF document outline, front matter first

24 days agor1870400018:37920replica 187040001828 operations, since the previous mark · compare with the head
+austenite: render doc template two-column title page, section logos, footer logo24 days agor1870400018:37891replica 18704000180 operations, since the previous mark · compare with the head
+git-494e734

austenite: render the doc template's two-column title page, section logos and footer logo

24 days agor1870400018:37890replica 187040001833 operations, since the previous mark · compare with the head
+austenite: group adjacent citations; restore fe2o3_graphics test harness24 days agor1870400018:37856replica 18704000180 operations, since the previous mark · compare with the head
+git-4153558

Merge branch 'lane/austenite-citegroup'

24 days agor1870400018:37855replica 18704000187 operations, since the previous mark · compare with the head
+git-ac93b03

Restore fe2o3_graphics test harness against current types

24 days agor1870400018:37847replica 18704000187 operations, since the previous mark · compare with the head
+austenite: fix i32 overflow in maths delimiter target for tall matrices24 days agor1870400018:37839replica 18704000180 operations, since the previous mark · compare with the head
+git-d8d4299

Fix i32 overflow in maths delimiter target for tall matrices

24 days agor1870400018:37838replica 18704000182 operations, since the previous mark · compare with the head
+austenite: term-dict lookups, list continuation, lone-file citations, and inline CeTZ/Fletcher figure evaluation24 days agor1870400018:37835replica 18704000180 operations, since the previous mark · compare with the head
+git-7df94e9

Merge branch 'lane/austenite-diagrams'

24 days agor1870400018:37834replica 187040001887 operations, since the previous mark · compare with the head
+git-4c1b98c

austenite: resolve citations when compiling a lone chapter

24 days agor1870400018:37746replica 187040001822 operations, since the previous mark · compare with the head
+git-41033c6

graphics/austenite: shape SVG <text> labels and decode embedded <image> rasters

24 days agor1870400018:37723replica 187040001822 operations, since the previous mark · compare with the head
+git-8d5b835

austenite: unnumbered banner headings and skip reporting for doc trees

24 days agor1870400018:37700replica 1870400018102 operations, since the previous mark · compare with the head
+git-965bf4b

austenite: a --watch recompile mode

24 days agor1870400018:37597replica 18704000189 operations, since the previous mark · compare with the head
+git-d2faa0d

austenite: rename the compiler binary to austenite

24 days agor1870400018:37587replica 187040001818 operations, since the previous mark · compare with the head
+git-511c7e4

austenite: read #link, term-dict glossary aliases, columns, and report skips

24 days agor1870400018:37568replica 187040001859 operations, since the previous mark · compare with the head
+git-2c3f007

austenite: render bold-italic emphasis and honour table align closures

24 days agor1870400018:37508replica 187040001838 operations, since the previous mark · compare with the head
+git-a0277a5

austenite: default the emit window to eight pages for speed

25 days agor1870400018:37469replica 18704000182 operations, since the previous mark · compare with the head
+git-d3a60a6

austenite: render standalone #line() horizontal rules

25 days agor1870400018:37429replica 18704000189 operations, since the previous mark · compare with the head
+git-8e26dcf

austenite: close small-scale fidelity gaps with the Typst oracle

25 days agor1870400018:37419replica 187040001881 operations, since the previous mark · compare with the head
+git-97abdfd

austenite: render SVG figures as native vectors

25 days agor1870400018:37337replica 18704000180 operations, since the previous mark · compare with the head
+git-77c3a16

fe2o3_graphics: a targeted SVG document reader

25 days agor1870400018:37336replica 187040001814 operations, since the previous mark · compare with the head
+git-442a5b4

austenite: colspan-aware table cells, set ragged

25 days agor1870400018:37321replica 187040001819 operations, since the previous mark · compare with the head
+git-83fed7e

austenite: full-bleed cover and a Part N label on divider pages

25 days agor1870400018:37301replica 187040001822 operations, since the previous mark · compare with the head
+git-28e8c64

austenite: equation cross-references

25 days agor1870400018:37278replica 187040001828 operations, since the previous mark · compare with the head
+git-bbd87e3

austenite: number display equations

25 days agor1870400018:37249replica 18704000182 operations, since the previous mark · compare with the head
+git-5f4b7d6

austenite: cross-references resolve to Figure/Table/Chapter N

25 days agor1870400018:37246replica 187040001810 operations, since the previous mark · compare with the head
+git-955b8f7

austenite: render heading titles in running heads and the TOC

25 days agor1870400018:37235replica 187040001813 operations, since the previous mark · compare with the head
+git-5b1055f

austenite: build tables from data-array spreads of any width

25 days agor1870400018:37221replica 18704000184 operations, since the previous mark · compare with the head
+git-25e8165

austenite: rich table cells and figure captions

25 days agor1870400018:37216replica 1870400018106 operations, since the previous mark · compare with the head
+git-852b0c2

austenite: table cell background fills

25 days agor1870400018:37109replica 18704000182 operations, since the previous mark · compare with the head
+git-b1e65f4

austenite: parse #emph and #super inline calls

25 days agor1870400018:37106replica 187040001821 operations, since the previous mark · compare with the head
+git-6aee640

austenite: render claim-label and claim-refs markers as invisible

25 days agor1870400018:37084replica 18704000184 operations, since the previous mark · compare with the head
+git-94af11a

austenite: rich heading titles so glossary calls render in headings and the TOC

25 days agor1870400018:37079replica 18704000180 operations, since the previous mark · compare with the head
+git-d993b13

austenite: fix glossary and index calls leaking inside emphasis runs

25 days agor1870400018:37078replica 187040001832 operations, since the previous mark · compare with the head
+git-115c518

austenite: resolve citations and emit the bibliography

25 days agor1870400018:37045replica 187040001847 operations, since the previous mark · compare with the head
+git-d577fb6

austenite: generate the table of contents

25 days agor1870400018:36997replica 187040001828 operations, since the previous mark · compare with the head
+git-6041ed8

austenite: title page and front matter

25 days agor1870400018:36968replica 187040001839 operations, since the previous mark · compare with the head
+git-b0dbf3c

austenite: render real raster images in figures, not a placeholder box

25 days agor1870400018:36928replica 187040001863 operations, since the previous mark · compare with the head
+git-7519576

austenite: heading font, chapter numbering and small caps

25 days agor1870400018:36864replica 187040001850 operations, since the previous mark · compare with the head
+git-7c0633f

austenite: verso/recto running heads

25 days agor1870400018:36813replica 187040001833 operations, since the previous mark · compare with the head
+git-238a7ab

austenite: first-line paragraph indent from config

25 days agor1870400018:36779replica 187040001821 operations, since the previous mark · compare with the head
+git-db88890

austenite: stream the PDF to a file, emitting each page and dropping its frame

25 days agor1870400018:36757replica 18704000180 operations, since the previous mark · compare with the head
+git-34b62e5

graphics: a page-at-a-time PDF writer that never holds the whole document

25 days agor1870400018:36756replica 187040001826 operations, since the previous mark · compare with the head
+git-650ec88

austenite: render figures, tables and footnotes

25 days agor1870400018:36652replica 187040001825 operations, since the previous mark · compare with the head
+git-1e0366c

austenite: a BibTeX reader and author-year citation formatting

25 days agor1870400018:36626replica 18704000183 operations, since the previous mark · compare with the head
+git-9f3d43f

austenite: render inline glossary and index terms; strip Typst comments

25 days agor1870400018:36622replica 187040001821 operations, since the previous mark · compare with the head
+git-cb2c5f5

austenite: skip multi-line template calls and code blocks

25 days agor1870400018:36600replica 187040001816 operations, since the previous mark · compare with the head
+git-ac79686

austenite: a function-plotting module, plots as first-class figures

25 days agor1870400018:36583replica 18704000186 operations, since the previous mark · compare with the head
+git-f74ec4f

austenite: parse Typst maths, $...$ into the engine's Atom tree

25 days agor1870400018:36576replica 187040001811 operations, since the previous mark · compare with the head
+git-1d83f92

austenite: inline code and fenced code blocks (Typst raw)

25 days agor1870400018:36564replica 187040001820 operations, since the previous mark · compare with the head
+git-c4432cf

austenite: read Typst markup, not a bespoke language

25 days agor1870400018:36543replica 187040001834 operations, since the previous mark · compare with the head
+git-7234b76

austenite: a ./dev live-preview pipeline for Ingot documents

25 days agor1870400018:36508replica 18704000184 operations, since the previous mark · compare with the head
+git-87920b0

austenite: cross-references in the Ingot language (#ref, #total-pages)

25 days agor1870400018:36503replica 187040001851 operations, since the previous mark · compare with the head
+git-63fce25

austenite: bullet and numbered lists in the Ingot language

25 days agor1870400018:36451replica 187040001826 operations, since the previous mark · compare with the head
+git-8cb228f

austenite: inline emphasis in the Ingot language (*strong*, /emph/)

25 days agor1870400018:36424replica 187040001837 operations, since the previous mark · compare with the head
+git-8934d92

jdat: decode a user-kind molecular payload as a plain-map value

25 days agor1870400018:36386replica 18704000182 operations, since the previous mark · compare with the head
+git-75ef185

austenite: tune maths layout to Latin Modern Math's own metrics

25 days agor1870400018:36383replica 187040001835 operations, since the previous mark · compare with the head
+git-766d108

austenite: grown radical and delimiters from the OpenType MATH table

26 days agor1870400018:36347replica 187040001821 operations, since the previous mark · compare with the head
+git-32cb510

austenite: set documents in Libertinus Serif against Latin Modern Math

26 days agor1870400018:36325replica 187040001821 operations, since the previous mark · compare with the head
+git-34ab01a

Resolve a nested user-kind list payload symmetrically with maps

26 days agor1870400018:36303replica 18704000183 operations, since the previous mark · compare with the head
+git-fabe9f2

austenite: set mathematics in Latin Modern Math, with per-glyph metrics

26 days agor1870400018:36299replica 187040001814 operations, since the previous mark · compare with the head
+git-3442842

Keep a nested user-kind value's molecular payload on text decode

26 days agor1870400018:36284replica 18704000183 operations, since the previous mark · compare with the head
+git-b7343b5

austenite: diagram sub-language -- nodes, routed edges, a typeset flowchart

26 days agor1870400018:36280replica 187040001813 operations, since the previous mark · compare with the head
+git-74d3320

austenite: Ingot front-end language, real maths font, and a figure seam

26 days agor1870400018:36266replica 1870400018111 operations, since the previous mark · compare with the head
+fe2o3_net: WebAuthn assertion verification, ES256/EdDSA26 days agor1870400018:36154replica 18704000180 operations, since the previous mark · compare with the head
+git-f8fe3d7

fe2o3_net: WebAuthn assertion verification, ES256/EdDSA

26 days agor1870400018:36153replica 18704000188 operations, since the previous mark · compare with the head
+Austenite Phase 5: mathematics layout26 days agor1870400018:36144replica 18704000180 operations, since the previous mark · compare with the head
+git-0c23bc0

Austenite Phase 5: mathematics layout

26 days agor1870400018:36143replica 187040001834 operations, since the previous mark · compare with the head
+Austenite Phase 4: footnotes26 days agor1870400018:36108replica 18704000180 operations, since the previous mark · compare with the head
+git-a32f908

Austenite Phase 4: footnotes

26 days agor1870400018:36107replica 187040001855 operations, since the previous mark · compare with the head
+Austenite Phase 4: tables26 days agor1870400018:36051replica 18704000180 operations, since the previous mark · compare with the head
+git-9ebb014

Austenite Phase 4: tables

26 days agor1870400018:36050replica 187040001811 operations, since the previous mark · compare with the head
+Fix the PDF xref test to search raw bytes, not a lossy string26 days agor1870400018:36038replica 18704000180 operations, since the previous mark · compare with the head
+git-b7b7347

Fix the PDF xref test to search raw bytes, not a lossy string

26 days agor1870400018:36037replica 18704000183 operations, since the previous mark · compare with the head
+Austenite Phase 6: PDF output, and a PDF writer in fe2o3_graphics26 days agor1870400018:36033replica 18704000180 operations, since the previous mark · compare with the head
+git-5999858

Austenite Phase 6: PDF output, and a PDF writer in fe2o3_graphics

26 days agor1870400018:36032replica 187040001812 operations, since the previous mark · compare with the head
+Austenite Phase 3: a table of contents through the ledger26 days agor1870400018:36019replica 18704000180 operations, since the previous mark · compare with the head
+git-cd0a6d2

Austenite Phase 3: a table of contents through the ledger

26 days agor1870400018:36018replica 18704000184 operations, since the previous mark · compare with the head
+Austenite Phase 2: a document of headings and paragraphs, with furniture26 days agor1870400018:36013replica 18704000180 operations, since the previous mark · compare with the head
+git-6e18576

Austenite Phase 2: a document of headings and paragraphs, with furniture

26 days agor1870400018:36012replica 187040001817 operations, since the previous mark · compare with the head
+Austenite: render a forward reference's resolved value26 days agor1870400018:35994replica 18704000180 operations, since the previous mark · compare with the head
+git-7862e6f

Austenite: render a forward reference's resolved value

26 days agor1870400018:35993replica 187040001817 operations, since the previous mark · compare with the head
+Austenite Phase 1: Liang hyphenation as flagged discretionaries26 days agor1870400018:35975replica 18704000180 operations, since the previous mark · compare with the head
+git-8a52407

Austenite Phase 1: Liang hyphenation as flagged discretionaries

26 days agor1870400018:35974replica 187040001830 operations, since the previous mark · compare with the head
+Austenite Phase 1: Knuth-Plass line breaking and justification26 days agor1870400018:35943replica 18704000180 operations, since the previous mark · compare with the head
+git-078c303

Austenite Phase 1: Knuth-Plass line breaking and justification

26 days agor1870400018:35942replica 187040001810 operations, since the previous mark · compare with the head
+Trim the retrofitted documentation off Austenite and fe2o3_font26 days agor1870400018:35931replica 18704000180 operations, since the previous mark · compare with the head
+git-ff50d41

Trim the retrofitted documentation off Austenite and fe2o3_font

26 days agor1870400018:35930replica 1870400018134 operations, since the previous mark · compare with the head
+Austenite Phase 1: shape real text and draw it as glyph outlines26 days agor1870400018:35795replica 18704000181 operation, since the previous mark · compare with the head
+git-7e84d35

Austenite Phase 1: shape real text and draw it as glyph outlines

26 days agor1870400018:35793replica 187040001861 operations, since the previous mark · compare with the head
+Add fe2o3_font, the shaping and outline layer extracted from Kiln26 days agor1870400018:35731replica 18704000180 operations, since the previous mark · compare with the head
+git-391b87e

Add fe2o3_font, the shaping and outline layer extracted from Kiln

26 days agor1870400018:35730replica 187040001854 operations, since the previous mark · compare with the head
+Add fe2o3_austenite, the Austenite Phase 0 typesetting spine26 days agor1870400018:35675replica 18704000180 operations, since the previous mark · compare with the head
+git-62038fd

Add fe2o3_austenite, the Austenite Phase 0 typesetting spine

26 days agor1870400018:35674replica 187040001819 operations, since the previous mark · compare with the head
+git-cc224ec

Add generic 2-D bar-joint rigidity analysis to fe2o3_geom

28 days agor1870400018:35654replica 18704000183 operations, since the previous mark · compare with the head
+Reset a nested map value's kind under a non-map user kind28 days agor1870400018:35650replica 18704000180 operations, since the previous mark · compare with the head
+git-eac7e1e

Reset a nested map value's kind under a non-map user kind

28 days agor1870400018:35649replica 18704000182 operations, since the previous mark · compare with the head
+git-00861f0

Guard the five-element Op::Settled shape with a test

29 days agor1870400018:35646replica 18704000181 operation, since the previous mark · compare with the head
+git-16a082e

Document that adding a field to any op is a breaking format change

29 days agor1870400018:35644replica 18704000181 operation, since the previous mark · compare with the head
+Add a sans-io RFC 5322 message parse and build to fe2o3_mail30 days agor1870400018:35642replica 18704000180 operations, since the previous mark · compare with the head
+git-3c2894f

Add a sans-io RFC 5322 message parse and build to fe2o3_mail

30 days agor1870400018:35641replica 187040001812 operations, since the previous mark · compare with the head
+git-9a16bde

steel: add an optional {file?:path} config reference

32 days agor1870400018:35628replica 187040001813 operations, since the previous mark · compare with the head
+A diff worth storing has to say what it was computed against34 days agor1870400018:35614replica 18704000180 operations, since the previous mark · compare with the head
+git-2f1acfd

A diff worth storing has to say what it was computed against

34 days agor1870400018:35613replica 18704000182 operations, since the previous mark · compare with the head
+Let a proposal's author state it again, without touching what they first wrote34 days agor1870400018:35610replica 18704000180 operations, since the previous mark · compare with the head
+git-6f602bd

Let a proposal's author state it again, without touching what they first wrote

34 days agor1870400018:35609replica 187040001848 operations, since the previous mark · compare with the head
+Say what damage at the tail of a store actually costs, because it is not what was assumed36 days agor1870400018:35560replica 18704000180 operations, since the previous mark · compare with the head
+git-d036041

Say what damage at the tail of a store actually costs, because it is not what was assumed

36 days agor1870400018:35559replica 18704000184 operations, since the previous mark · compare with the head
+Gate the raw insert and get_data WebSocket commands on an operator session37 days agor1870400018:35554replica 18704000180 operations, since the previous mark · compare with the head
+git-b105624

Gate the raw insert and get_data WebSocket commands on an operator session

37 days agor1870400018:35553replica 18704000184 operations, since the previous mark · compare with the head
+Gate the terminal management commands on an operator session too37 days agor1870400018:35548replica 18704000180 operations, since the previous mark · compare with the head
+git-23c6eaf

Gate the terminal management commands on an operator session too

37 days agor1870400018:35547replica 187040001811 operations, since the previous mark · compare with the head
+Gate the terminal WebSocket route on config and an operator session37 days agor1870400018:35535replica 18704000180 operations, since the previous mark · compare with the head
+git-4667584

Gate the terminal WebSocket route on config and an operator session

37 days agor1870400018:35534replica 18704000187 operations, since the previous mark · compare with the head
+Remove a server config knob that nothing has ever read37 days agor1870400018:35526replica 18704000180 operations, since the previous mark · compare with the head
+git-3f4ea42

Remove a server config knob that nothing has ever read

37 days agor1870400018:35525replica 18704000183 operations, since the previous mark · compare with the head
+git-26b6642

Let one watched machine say it cannot hold a certificate

38 days agor1870400018:35521replica 18704000180 operations, since the previous mark · compare with the head
+git-1e8e59a

Give the publish store's tests the field the record grew

38 days agor1870400018:35520replica 187040001827 operations, since the previous mark · compare with the head
+git-e1e600f

Answer how long an encoding will be without performing it

38 days agor1870400018:35492replica 187040001815 operations, since the previous mark · compare with the head
+git-d61fb23

Read a DER key by its own declared length, and find one that is not at the front

38 days agor1870400018:35476replica 187040001829 operations, since the previous mark · compare with the head
+git-2591079

Finish the curve list, and say where every byte actually came from

39 days agor1870400018:35446replica 18704000186 operations, since the previous mark · compare with the head
+git-6017b67

Catch a private key that has no text shape, by the bytes DER puts in front of one

39 days agor1870400018:35439replica 187040001810 operations, since the previous mark · compare with the head
+git-06c180c

Let a reader take a record's digest on trust, and say what that costs

39 days agor1870400018:35428replica 187040001819 operations, since the previous mark · compare with the head
+Let a reader take a segment up where the last one stopped39 days agor1870400018:35408replica 18704000180 operations, since the previous mark · compare with the head
+Report the revocation seam as open in the format registry39 days agor1870400018:35407replica 18704000181 operation, since the previous mark · compare with the head
+X25519 key agreement, and the key distribution names fixed in the registry39 days agor1870400018:35405replica 18704000189 operations, since the previous mark · compare with the head
+Say which mark closes each operation, so a reader can name an author40 days agor1870400018:35395replica 18704000183 operations, since the previous mark · compare with the head
+Ask the address guard from outside, since reading it has been wrong before40 days agor1870400018:35391replica 18704000182 operations, since the previous mark · compare with the head
+Say what Ore ignores, and keep its replica out of git

`ore import` puts this repository under Ore, and every capturing verb after it walks the working tree. The tree carries 52 GB of `target/`, and Ore reads `.oreignore` rather than `.gitignore`, so without this file the first verb after the import would ingest all of it into a log that cannot forget. The rules are `.gitignore`'s, verbatim, because they are already right: proved against a mirror of this repository's real path set, where Ore's engine and `git ls-files --others --exclude-standard` agree on 1,712 paths with no difference in either direction. `/target/` prunes at the directory entry rather than testing what is under it. Three lines are added that `.gitignore` does not need. Ore reads only the root ignore file, so `fe2o3_namex/.gitignore` has no effect and the 24 kB of `namex_echo` output it hides is named here instead; and `target/`, unanchored and directory-only, catches a build directory under a subcrate that root-anchored `/target/` would let through. `/.ore/` goes into `.gitignore` for the other direction: the store, its bookkeeping and the git mirror it writes all live in that directory, several sessions share this tree, and none of it belongs in git.

40 days agor1870400018:35306replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+A proxied multipart upload lost its top level, and its boundary its case

`ContentTypeValue`'s Display wrote the multipart SUBTYPE and not the type: `multipart/form-data; boundary=X` came back out of the parser as `form-data; boundary=X`. Steel puts that byte-for-byte on the wire to its upstream, whose own parser then refuses it -- "Invalid Media type 'form-data', '/' character not found" -- and drops the connection without writing a response. Seen six times on the live Oregami forge, and the access log records each one as `200 (0 body bytes)` because a status that was never written reads as zero and is normalised to 200. The header carried a second fault on the same line. The whole value was lowercased before parsing, so `boundary=----WebKitFormBoundaryAbC` reached the upstream as `----webkitformboundaryabc`. A multipart boundary is case sensitive (RFC 2046 s5.1.1), so every real upload through the proxy would have arrived with a delimiter matching nothing in its own body -- a fault the render bug was hiding, since nothing got that far. The media type and the parameter name are still matched case insensitively; only the parameter value keeps what was sent. `test_a_multipart_content_type_survives_a_hop_00` asserts both properties over `build_proxy_request_head` and then re-parses what the hop wrote, which is the failure as the forge met it. Each assertion was proved red against its own defect and green against the other.

40 days agor1870400018:35302replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Pack a run of records into one record, and say so in the record

A segment's records are written plainly, and compressing them is worth about 39 MB of 63 on a real history. The saving is redundancy BETWEEN records, and a record is about 1.4 kB, which is too small a window to see any of it: deflating each record alone reached 58.8% where deflating runs of a megabyte reached 38.4%. So one packed record carries a run, each run inflatable on its own, and reaching a record costs its own megabyte rather than the whole segment. KIND_PACKED is a kind and not a header field, on the veil's precedent and for the reason the veil's own doc gives: the kind byte says what was done in the record where it is, rather than in a header that would condemn every plain record beside it. A header field could not have been free anyway, since entries begin immediately after the header and a new field would make an old reader parse it as the start of an entry. So segment::VERSION does not move, VERSION_MIN stays at 2, both segment golden tests freeze the same bytes they always did, and a segment holds packed and plain records side by side. What goes under the compressor is the framing those records would have had written plainly, digests included. So a packed segment yields the same records with the same digests in the same order as the plain one it was made from, which is what keeps a fold over those digests -- the thing a repack compares two stores by -- identical on both sides, and it is what makes packing revocable rather than a door. The run's own digest covers the compressed bytes and is checked before a byte is inflated; it is not tallied, because what a fold names is records. An inflate is bounded by PACKED_MAX, since a compressed frame is an instruction to allocate and it arrives from wherever the segment did. deflate through flate2, which resolves to miniz_oxide: pure Rust, and it builds for wasm32 like the rest of this crate, where zstd binds C. At the megabyte frames this format uses zstd's advantage is 2.9 points rather than the 4.5 it shows over a whole segment, because a frame that size neutralises most of its long range matching. The level is not recorded and no golden test freezes a packed payload: what a compressor made of the records is not a fact about Ore, and freezing it would freeze a dependency version and call it a format. The registry's five open rows are closed, and they move rather than vanish: a `settled` section records the answer and keeps guarding the spellings the answer ruled out, so the second lane to arrive cannot build the alternative that was considered and rejected without a test saying it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35292replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

40 operations, since the previous mark · compare with the head
+Put the format's own names where a test can check them

The version constants, wire codes, kind bytes and golden-byte tests were written down in a design document, which named snapshot::VERSION at a line in a file that had been deleted four days earlier and listed a golden test that had gone with it. Nobody noticed until somebody was asked to look. format.jdat carries the same facts as data, beside the code they describe, and tests/format_registry.rs asks the tree whether each is still true: that a constant is declared where the registry says and holds the value it declares, that every public constant in a format bearing file is registered or exempted, that the registry names every golden test and no others, that a frozen array still stamps the constants it pins, that the version table says what highest_code says and covers every version from VERSION_MIN upward, that a name recorded as removed is gone, and that no name still recorded as open has been invented. The last of those is what a lane reporting a seam has lacked. A question the owner has not answered guards the spellings whose appearance would mean somebody answered it in passing, so inventing one fails a test rather than waiting to be read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35251replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Decide where a name ends and a moment begins in one place

The author trailer's spelling went upstream so a mirror and a forge could not drift about what a mark says, and then the interesting half of the rule stayed downstream in two copies: `mirror::split_moment`, which wants the offset, and `forge::named_in`, which wants the name. They agree because they were written to agree, which is the arrangement the move was made to end. It goes in `fastexport` rather than beside `AUTHOR_TRAILER` in `op`, and that is not a preference. The clean signature returns a `When`, `fastexport` already imports `op::Mode`, and `op` importing `fastexport` back would be a module cycle -- so `op` would need a second moment type of its own, duplicating `TzOffset` to avoid a cycle, to hold a value whose format is git's. The format is `fastexport`'s subject and `When`, `TzOffset` and `Person` are already there. `AUTHOR_TRAILER` now points at it, and the sentence saying a caller splits the tail itself is withdrawn. `parse_when` does the deciding, being the one reader of git's raw date format here, so the all-or-nothing rule is not restated: its refusal is what "there is no moment on the end of this" looks like. The forge's guarded cases come with it as a doctest, `J H <j@h.test>` among them -- a line that splits on spaces perfectly well and would come back as `J` under a rule that only counted fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35246replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+A startup control operation was given a user request's deadline

A gateway with a 2.6 GB store could not start at all. Database initialisation completed, activate_gc sent GcControl to the zone bots, and neither answered within the six seconds of USER_REQUEST_WAIT because both were still surveying the store's files. Measured after this change: survey to listening takes about eight seconds. It was dying at six. The deadline was the defect, not the store and not the caller. A user request is given a short deadline SO THAT a slow request path is a visible error rather than a hang; a control operation runs once, has no client blocked on it, and its failure is total. Waiting is nearly free there and failing is expensive, which is the reverse of a user request, so CONTROL_REQUEST_TIMEOUT is five minutes -- bounded rather than unbounded, because a bot that is genuinely dead must eventually be reported instead of waited out for ever. Raising USER_REQUEST_TIMEOUT instead would have loosened every user request to hide a startup problem, and the comment at the constant says so. `scan` keeps its six seconds and gains `scan_with_wait`. The short deadline is what makes "nothing on a request path may walk the store" enforceable rather than advisory, so it stays the default and a deliberate background walk names its own -- visible at the call site instead of buried in a shared constant. get_zone_dirs deliberately keeps the short one: it reports state the bots already hold, and a five-minute hang on an inspection call would be a new defect. The ordering the constants have always needed is now checked rather than commented. "User timeouts must last longer than internal bot timeouts to avoid lockups" was true and unenforced; O3db::new refuses a configuration that breaks it. AND THE FAILURE IS LOUD. The old error was accurate and useless -- "Expecting 2 messages via responder, received 0 when timed out after 6s" -- naming the count and the deadline and nothing about what was attempted or what to do. It cost four wrong diagnoses over two hours before anyone read the gateway's log. Both seams now name the operation, the likely cause, the governing constant, AND the wrong fix as wrong. No signature moved, so no call site needed changing -- confirmed by a grep of the whole of ~/usr/code rather than the crates in hand, which turned up three consumers nobody had named: powls, Oregami, and eleven sites in Ochre. A peer session took a live service down for hours yesterday by adding an argument to a shared function and checking one of its two consumers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EaQ7hg4AkqViSRZhasMB9A

40 days agor1870400018:35243replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Say what the author trailer's value is, which is not just a name

The importer writes git's whole author line into the trailer -- `Name <email> 1735089438 +0800` -- because `Op::Mark` holds a time in UTC and no zone, so the offset an author's own clock was reading survives an import only here. The doc described the value as the identity line and the example showed one without a moment, which understates it in the direction that bites: a forge reading the trailer to show a person a name, and printing the value whole, prints a timestamp in the middle of it. Neither function looks at the shape, so nothing behavioural moves. This is the description catching up with the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35230replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Stop naming a module that is not there any more

The crate root still listed `snapshot` among its modules, which is an unresolved intra-doc link since the format went in 2e956a4, and RepoNote's note still derived its file views from a snapshot rather than a render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35225replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Put the author trailer's spelling where all three of its readers can reach it

`ore import` records each git commit's original author line as an `Ore-Author: ` trailer on the mark it becomes, so that the mirror can write the commit back under the name it was written under. Three programs have to agree about that line: the importer writes it, the mirror reads it back, and a forge reads it to show a person the name rather than the bookkeeping. Two of them are in `ore_cli`, which is a binary crate with nothing to depend on, so the third had no way to reach the constant and would have spelled it a second time. This is where `AUTO_MARK_PREFIX` already lives and for the same reason, so `AUTHOR_TRAILER`, `with_author` and `without_author` join it beside `Op::Mark`. Nothing here is a new rule: `without_author` is `ore_cli::mirror`'s own, taking `&[u8]` rather than allocating, and `ore_cli` should now delete its copies and call these. Nothing existing changed, so nothing that compiled stops. The rule worth having in one place is **exactly one line comes off, and never a loop**. A git commit message may itself end in a line beginning `Ore-Author:`, and the import writes its own after it; a reader that stripped until no trailer remained would author the commit under the name in the *person's* line and delete that line as it went. The doctest carries that case, and against a looping version it fails on it -- proved before the wording was written. What the split cannot decide is written down beside it: a mark authored in Ore whose body's last line a person typed as `Ore-Author: ...` is indistinguishable from an imported one. Nothing in a mark says whether it was imported, and adding something would be a discriminator in the history for one importer's benefit. 341 tests and 3 doctests in fe2o3_ore. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35222replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Hand back the digest a record was checked against

A segment reader computes a digest over every record to check it and then drops it, so a caller wanting to name the exact bytes it read had to hash the whole segment again: 205 ms over a 55 MB segment, against 7 ms of folding digests already paid for. `Reader::tallying` keeps them and `Reader::take_digests` drains them, so a caller working a batch at a time never holds more than a batch of them. Nothing about the format moves. The digests are the ones already written and already compared; only the dropping of them changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35220replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Refuse a reference component longer than a file name may be

`check_refname` exists to answer whether git will accept a name, and it said yes to three that git refuses. Naming a mark after a commit's subject gave fe2o3's import mark names of 259, 297 and 312 characters; a loose reference is a file named after the component and a file name is 255 bytes, so `git fast-import` applied the whole stream and then refused it over the refs it could not create -- on stderr, after the caller's command had already returned successfully. Bytes and not characters, and the difference is the point: 200 accented letters are 200 characters and 400 bytes in the directory entry. Each component is asked separately, since the limit is on the file name and not on the path, so a long name may hold two long components and no illegal one. Git states no such rule, which is why the function's doc now says where this one comes from instead of resting on `git-check-ref-format(1)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35204replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Know a credential when a file carries one

A credential written into a source file is stopped today by a global git pre-commit hook, which is a hook and therefore covers git and nothing else. A tool that writes history another way has no such protection, and its shapes, its placeholder excuses and its `allowlist secret` marker have to be the same ones or a fixture marked for one tool is refused by the other. So the hook's two classes move into the library, over bytes rather than str, since a source file carrying one invalid UTF-8 byte is exactly where an unnoticed key would sit. The shapes are matched by hand rather than through the regex engine: each is a literal opening and a run of one character class, which one pass along the line decides, at about 190 MB/s over real source. Checked against the hook itself over a corpus holding every class, every placeholder and every spelling of the marker: the two agree finding for finding. The module also holds the PEM header in two halves, so that a scanner reading this file finds no header in it -- which the hook established by refusing the first draft of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7yKwfzCJVYoi6FVKcvQKf

40 days agor1870400018:35199replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+And fewer again, in the tests that were arguing rather than saying

A second pass over the same diff. The property test's doc gave a paragraph to each of its three shape families where a clause each was owed, the skipped counter test explained Lamport minting twice over, and `is_indexed` described what the index is instead of what the accessor answers. fe2o3_ore's added documentation goes 304 per kloc to 200 across the two passes, against 44 the author writes. What remains is mostly the reason a test exists and what it would catch, which is the convention this crate already keeps and the thing the red-then-green discipline rests on. 334 lib tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

40 days agor1870400018:35191replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Say the ancestry index in fewer words

rc-4 measured the documentation this lane added and it is the worst in the library: 156 doc lines against 514 of code across `49d3b67..HEAD`, 304 per kloc, where the author writes 44 and where the sweep that ran three days ago was built to remove a rate of 170. The crate as a whole reads low, because 27 swept files mask 514 new lines, which is why the diff is the unit and not the crate. One of them was a flat rule violation rather than a matter of taste: `VECTORS_ENTRIES_MAX` carried a nine line `///` block, and a constant takes an aligned trailing note. The rest were essays where a sentence was owed. The version vector's own doc kept the fact a reader could not derive, that a column is a run because a replica forks against itself twenty-nine times in the measured history, and lost the derivation around it. `build`, `fill`, `is_graded`, `index_runs` and the two comments inside `reaches` all say the same things in about half the room. Nothing was moved from `///` to `//` to flatter the measurement. The house rule already prefers the words inside the function, and the inline comments came down with the rest. 334 lib tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

40 days agor1870400018:35182replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

24 operations, since the previous mark · compare with the head
+Take the snapshot format out, now that a render is cheap

A snapshot was a materialised copy of the whole working tree, written beside the log so that a reader could see the present without replaying the history. It was worth having when replaying cost fifteen seconds. It is not worth having now. Measured on a 44,629 operation history, against the tool with the snapshots removed: a current one saved about two tenths of a second and held 67 MB of memory to do it, on top of 31.8 MB of disk. It was also unusable for almost every command, because the frontier moves the moment anything is appended and the next was written only after the log had grown by half a tree, which on that project is about ten days. So ORESNP goes, and with it `Rendered::from_parts` and `Repo::from_states`, which existed to rebuild a repository from a snapshot's flattened states and had no other caller. `a_mode_rides_the_snapshot` goes too: it existed to show a file mode surviving the round trip, and there is no round trip. Nothing is lost that was not derived. The asymmetry the module's own header stated still holds, and it is why this deletion is safe where deleting a segment would not be: nothing is lost by discarding a snapshot, and everything is lost by discarding a segment. 334 lib tests, down from 352 with the format's own suite gone, and the tool's 35 targets stay green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

40 days agor1870400018:35157replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Answer an ancestry question by lookup, not by walking the graph

`Causality::reaches` was the engine's hottest function by a wide margin. Bounding its walk by counter took the render from 14.8 s to 4.9 s on a 44,629 operation history, and left roughly 4.5 s of that still walking, because the bound only narrows the window and a genuinely concurrent pair sits inside it. `Causality::new` now builds a version vector per operation and `reaches` reads the answer out of it. The render on the same history goes from 5.4 s to 0.26 s. The obvious index keys a column per replica, and it is refused on the very history it was written for. A replica does not always name its own previous operation: an imported history takes its parents from the shape it was imported from, and this fixture forks a replica against itself in 29 places. So the build cuts a replica at each fork and gives every unbroken piece a column of its own. Within a piece, each operation has the one before it in its ancestry by construction, which is exactly the property the lookup needs. Two replicas over this history become 31 runs and 12.4 MB. The index is refused, and the walk kept, wherever it cannot be trusted: an ungraded graph, a parent the graph does not hold, a chain check that fails after the cut, or a matrix past the 32 MiB ceiling. `reaches` gives the same answer either way, always, which is the whole contract. A counter nobody spent is the trap. Lamport minting leaves gaps in a replica's counters, so an identifier the graph does not hold can sit below the vector's value for its run, and the lookup has to refuse it by asking whether the operation exists rather than by comparing numbers. A history whose counters happened to be consecutive would never show it. Proved against six deliberate breaks, each restored: never cutting a fork, cutting only every other one, dropping the absent-target guard, dropping the missing-parent check, dropping the grading gate, and never building the index. Each reddens with a message naming the pair that disagreed or the shape that was wrongly admitted. The property test now covers three shape families and asserts it saw all three. 352 lib tests, and the 36 targets of the tool, stay green. Cross-checked outside the test suite as well, on the real history rather than a generated one: 4,000 pairs against an exhaustive walk, 1,985 of them reachable, no disagreements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

41 days agor1870400018:35151replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+Give a snapshot's file name one writer and one reader

The name was built by a `format!` in the tool, read back by an extension test in the store, and reconstructed a third time by string surgery in a test -- three spellings of one rule, and no reader that could turn a name back into the operation it names. A reader that cannot do that cannot order the files it finds, which is why `snapshot_at_frontier` was taking whichever snapshot the directory happened to yield last. `file_name` and `named_at` are a pair, upstream where the format is, for the reason `AUTO_MARK_PREFIX` moved upstream: two crates apply the rule. The substitution they exist for is the colon -- an identifier is spelled `r<replica>:<counter>` and that is not a filename everywhere. `named_at` refuses anything it did not write, which is how a torn write, an editor's backup and a foreign file are skipped by one test rather than by a rule each reader keeps for itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

41 days agor1870400018:35129replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Check conservation where the render is still holding what it needs

The render checked conservation only under a debug build, so a caller wanting the check in release called `check_conservation` -- which rebuilt the atoms, the tombstones, the overlap arbitration and a whole trial layout to ask a question the render had just had the answer to. Both callers in Ore did exactly that. Measured on a 44,629 operation history: 5.51 s of a 20.2 s open, for an answer already available. Checked in place it costs 1.3 s, and once the ancestry walk was bounded it costs 0.13 s. So the check now runs in every build, at the end of `render_with`, against the atoms and tombstones that render used. `check_conservation` stays for what its name suggests -- a repository assembled some other way, or one a test has damaged on purpose -- and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

41 days agor1870400018:35125replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Stop walking the whole history to answer one ancestry question

`Causality::reaches` walked the entire parent graph, with no bound and no memory, once per question. A `false` answer had to exhaust the ancestor set to be sure of itself, and `concurrent` asks for two of them -- so the pair the repository exists to handle, two genuinely concurrent edits, was the pair that cost the most. Measured on a 44,629 operation history: 12,128 calls popped 187 million nodes, and three stages of the render -- `yields`, `stranded` and `overlaps` -- spent 98% of their time in this one function. Nothing else in the render was near a bottleneck. An operation takes the counter one past the highest the log holds, so every parent edge steps strictly downwards. Nothing at or below the target's counter can reach it, and a branch that has fallen below it can only fall further. The walk is then bounded by the counters between the two operations instead of by the size of the history. That rule is NOT enforced. `OpLog::append` requires a parent to be present and a replica's own counters to rise, and neither forbids a hand-assembled graph whose child sits at or below its parent -- `an_operation_before_its_parent_is_refused` builds exactly such a pair. So `Causality::new` measures the property in one pass over the edges and `reaches` asks before it leans on it; an ungraded graph is walked as it always was. The new test builds both kinds and compares every pair against an exhaustive walk kept beside it as the definition. It was proved against the unconditional bound first, which it catches on its first ungraded shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FD47XinXuf9CiXPT8QK5AP

41 days agor1870400018:35119replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Let a rendered repository be built from a snapshot's states

`Rendered::from_parts` and `Repo::from_states` take what a snapshot holds and present it as a render, for a reader that wants to see the state rather than merge into it. `Snapshot::into_files` hands the states over instead of lending them, because a snapshot of a large tree is most of a working copy and copying it to read it doubles the largest thing in memory at the moment it is least affordable. A repository built this way carries no placement index, no repository-level notes and no deleted files, because a snapshot holds none of them. Said in the doc comment, since a caller needing any of those must render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBLKZZGp2dDYcL27BWdCBL

41 days agor1870400018:35111replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Do not lay out bytes for a walk that only asks where they went

`Sequence::layout` wants one thing: which file each slot ended up in. It was getting that by rendering the entire working tree into `BTreeMap<OpId, (Vec<u8>, Vec<Run>)>`, keeping `owner`, and dropping every byte it had just written. A whole copy of the tree computed and never read -- and it happened twice on every repository open, once under `render_with` and once under `check_conservation`, both of which reach `layout` through `birth_files`. `traverse` now takes an `Emit`. `Emit::Bytes` is the render and is unchanged. `Emit::OwnersOnly` walks the same forest, in the same order, and fills `owner`, `orphans`, `orphaned` and `max_depth` exactly as before, while allocating nothing for content. The orphan tally is deliberately on the common path: it is a fact about ownership, which is what the cheap walk is for. MEASURED on a real 44,628-operation history, release, three runs each: before 263,136 kB 5.79 kB/operation after 244,064 kB 5.36 kB/operation 19.1 MB, 7.4% less The figure was found the cheap way first, and that is worth recording as method: disabling `check_conservation` outright removed one of the two discarded renders and took the peak to 243,380 kB. That said the cost was real and where it was, in twenty minutes, before a line of the actual fix was written. This change gets the same saving and KEEPS the conservation check, which was never the problem -- the waste was in what `layout` asked for, not in what the check does with it. Attribution by reading finds where bytes are; only a measurement finds WHEN. An envelope-payload change tried the same day looked better on paper -- the signed bytes are held twice and that is 41% of the total -- and made the peak 2.3 MB WORSE, because the two copies do not coexist at the maximum. It was reverted and written up in the audit note. Still above the 5 kB per operation budget, which stays missed. 345 engine tests, the whole ore workspace, and the render output unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBLKZZGp2dDYcL27BWdCBL

41 days agor1870400018:35107replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Stop counting the sub-second twice, and stop the day going backwards

`CalClock::to_nanos` added the whole nanosecond-of-second field to a millisecond figure that already carried the whole-millisecond part of it, so the result overcounted by up to 999 ms and RAN BACKWARDS at every second boundary. Ordering is built on that number, so `is_before`, `is_after` and every comparison derived from them answered wrongly across a second. Only the sub-millisecond remainder was ever missing, and zone offsets are whole milliseconds, so nothing below a millisecond is lost on the way in. Two more found while fixing it, both in the inverse: - `from_millis` divided with truncation, so a pre-epoch instant landed on the day ABOVE it with a negative millisecond-of-day, which then wrapped when cast to u32. 1969-12-31T23:59:59.999Z was the whole of 1970-01-01 at an enormous offset. Euclidean division floors instead. - `from_nanos` REPLACED the sub-second field that `from_millis` had just set rather than adding to it, throwing the millisecond part away and breaking the round trip with `to_nanos` for any instant with milliseconds in it. Both new tests were proved against the broken code before the fix landed: the monotonic one steps across a second boundary and fails naming the direction, the known-value one is checked against a day count derived by hand rather than against the function's own output. Comment corrections in `fe2o3_ore`, no code touched. Two carried bad arithmetic of mine: 344 bytes an operation is 4% of the memory ABOVE the binary's floor and was written as though the rest were all content, when it also holds envelope payloads, a segment read whole and BTreeMap nodes that split badly under ascending keys; and `Atoms::total` was said to over-report "by up to three", when what it actually reports is bytes an atom can be read for rather than bytes this map keeps alive -- a different quantity, not the same one scaled. NOT FIXED, and pre-existing: nine failures in `fe2o3_datime`'s calendar tests. Established as pre-existing by running the suite with these files reverted -- 245 passed, 9 failed, identical both ways. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBLKZZGp2dDYcL27BWdCBL

41 days agor1870400018:35099replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Take the four dependency fixes that matter, and say why the fifth waits

GitHub reported 12 vulnerabilities on the default branch, 1 high. Dependabot counts every unmaintained-crate notice as a vulnerability, so the number overstates: `cargo audit` finds FIVE real ones and 18 advisory notices. The 2026-04-16 triage in memory says not to chase the count, and it is right; what it did not have is the high, which is newer than it. Fixed, by `cargo update` within existing semver bounds: - rustls-webpki 0.103.12 -> 0.103.14 (RUSTSEC-2026-0104). A REACHABLE PANIC in certificate revocation list parsing, published 2026-04-22, after the last triage. This is the one that matters and the reason not to defer the lot: fe2o3_net's TLS is what Steel serves the public web and karri's mail on 25/587/993 with, so a panic reachable from certificate handling is a denial of service on live infrastructure rather than a theoretical one. - ring 0.17.8 -> 0.17.14 (RUSTSEC-2025-0009). AES panic under overflow checking. The earlier triage called it no real risk because release builds disable overflow checks, which is still true; it is taken now because it came free with the others. - crossbeam-channel 0.5.14 -> 0.5.16 (RUSTSEC-2025-0024), double free on Drop. - crossbeam-epoch 0.9.18 -> 0.9.20 (RUSTSEC-2026-0204), invalid pointer dereference in a `fmt::Pointer` impl. Carried along by those: aws-lc-rs 1.16.2 -> 1.18.0 and aws-lc-sys 0.39.1 -> 0.44.0, which is a C build and therefore the part worth checking rather than assuming. `cargo check --workspace` is clean. NOT FIXED, deliberately: time 0.3.37 (RUSTSEC-2026-0009, DoS via stack exhaustion on a crafted parse, 6.8 medium). The fix needs >= 0.3.47, which needs serde >= 1.0.229, which REMOVES `serde::__private` -- and `swc_common` and `swc_config` import that path. swc is a forty-crate tree under fe2o3_steel's JS bundler, so taking this fix means upgrading the whole swc stack underneath the server that runs live mail. That is a refactor with real risk, dressed as a security update, to close an advisory the 2026-04-16 triage already established is not on an attacker path here: fe2o3 parses time with its own fe2o3_datime, and `time` is transitive and reachable from no input a stranger controls. So it waits for a deliberate swc upgrade with its own testing, and this commit records why rather than leaving a future reader to rediscover the conflict. Proved rather than assumed: serde 1.0.229 was taken, the workspace was checked, and swc_common and swc_config failed to compile on `serde::__private`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBLKZZGp2dDYcL27BWdCBL

41 days agor1870400018:35084replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+One buffer with three owners, not three copies of it

Opening a repository of real size cost 7.63 kB of resident memory PER OPERATION -- 345,272 kB for 44,628 operations, and rising by roughly 1,300 operations a day on the largest history we have. Measured by rc-1, whose finding reframed the whole thing: `ore log` touches no network and costs the same as a clone, so this was never a transport problem. Every verb that opens the repository pays it. The cost is not the structures. `Op` is 112 bytes and `Record` 152, so the fixed per-operation cost across the log, `Applied` and the `Atoms` key is about 344 bytes -- 4% of it. The other 96% is the content of `Op::Splice { insert }`, which was held three times over: once in the log's record, once in the sequence's cloned `Applied`, and once more in `Atoms`. So `insert` is an `Arc<[u8]>`. The bytes exist once and the three structures point at them: `Sequence`'s clone of an operation is now a refcount bump, and `Atoms` holds a handle rather than a copy. Nothing borrows and no lifetime appears anywhere, which is what makes this a wide shallow diff -- the type, its two format sites, and the construction sites -- rather than lifetime parameters threaded through the renderer. MEASURED, on the real 44,628-operation store rather than a fixture: before 345,272 kB 7.63 kB/operation after 263,136 kB 5.79 kB/operation 24% less THE FORMAT DOES NOT MOVE, and that was proved before the construction sites were touched, because if it did move every existing store would be data nobody can open. `Dat::BU64` receives the same byte sequence whatever owns it -- the encoder already copied into the daticle, so there is no new cost there either. Confirmed twice: the golden-byte tests pass unchanged, and the changed binary read an existing 44,628-operation store written by `cb617c9`, reported the same frontier, and left both segment files byte-identical by md5. `Atoms::total` needed its doc rewritten rather than its code. It sums the length of every atom, and those bytes are now shared with the records they came from, so "bytes held" quietly stopped meaning what this structure costs and started meaning what it can see. A caller using it as a memory figure would over-report by however many owners each buffer has -- during a memory optimisation, which is the worst possible moment for a measurement to change meaning under its own name. Caught by rc-4 reading the diff's path, not by me. WHAT THIS DOES NOT DO: it does not reach the 5 kB per operation that rc-1 set as the target, and my prediction that sharing would beat the arithmetic -- fewer allocations, exact sizing over `Vec`'s capacity slack -- was wrong. That is the third confident prediction about this memory today and the third to fail on contact with a measurement. 258 MB per 44,628 operations is still resident with the content held once, and where it lives is now a profiling question and not a reasoning one. No guard is added here, because a guard at 5 kB would be red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBLKZZGp2dDYcL27BWdCBL

41 days agor1870400018:35067replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

64 operations, since the previous mark · compare with the head
+The lock catches up with fe2o3_file's dependency on fe2o3_stds

fe2o3_file/Cargo.toml has named oxedyne_fe2o3_stds since the office module began reading media labels from it, and the manifest change went in without the lock entry it implies. Left uncommitted the file is a hazard rather than a nuisance: fe2o3 is one shared working tree, so any lane running git add -A sweeps it under an unrelated message, which is how two lanes' work was mislabelled on 17 August.

44 days agor1870400018:35002replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+The mail clients get a net that bites, and is_permanent starts working

53 tests where there were none that could catch anything: three real breaks -- a STARTTLS downgrade accepting pipelined bytes, dot-stuffing removed, and the login moved after the envelope -- all shipped clean through 296 passing tests before this. is_permanent had never returned true. res! wraps a cause in Error::Upstream with empty tags and Error::tags() reports one frame, so the Permanent tag on a 5xx was invisible one wrapper away. Steel has therefore never suppressed a bounced subscriber and every newsletter report has read suppressed: 0. The sans-io refactor is NOT here: it was reverted deliberately when the week's quota ran short, because a half-refactored IMAP client in a tree carrying live mail is the worst place to stop.

44 days agor1870400018:35000replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+A seam at the MX boundary, so the path that carries real mail can be tested

deliver resolved MX records and then ran the loop that matters -- preference order, which failures earn another exchange, whether the collapsed error is permanent -- and none of it could be reached by a test, because try_one hard-coded port 25 and the targets came only from a live lookup. It carries jarrah's outbound mail and had no coverage of any kind. deliver_to_exchanges takes the exchanges as an argument. It is private and reads nothing from configuration: this is not a way to say where mail goes, it is a way for a fixture to stand in as an exchange. DeliveryTarget gains a port field, always 25 from the resolver, so a stand-in can sit on a loopback port. Eight cases. Delivery must NOT authenticate even where the exchange advertises AUTH, since it is a mail server's conversation and not a mail client's; an advertised STARTTLS refused with 454 must still deliver in the clear, because opportunistic means opportunistic and refusing would stop mail to any exchange having a bad day with its certificate; a 550 stays permanent through the collapse while a 450 does not; and one permanent refusal survives a later transient one. The preference case earned its comment. Written with a banner-refusing exchange it passed with sort_by_key deleted -- a fixture that records nothing has an empty transcript whether or not it was ever dialled, so the assertion could not fail. It refuses at RCPT TO now, which leaves a trace that distinguishes the two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:34999replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Switch the net suite on, and let it fail where it has been failing unseen

One word at tests/main.rs:29 was "dns". test_it matches a tag by prefix and no tag in email.rs, http.rs, smtp.rs or smtp_submit.rs begins with "dns", so every case in all four was skipped -- while the target printed "running 1 test ... ok" in 0.00 seconds. A filtered-out suite reads exactly like a passing one, which is why nobody looked. Now "all", and the suite goes red on two cases that have been dead since they were written. Neither is fixed here, because neither is this lane's: email.rs:76 'Mbox reader 000' opens /home/jason/tmp/Inbox and fails NotFound. It can only ever have passed on the machine that happened to hold that file. http.rs:36 declares Content-Length: 25 over a 26-byte body, so the reader correctly returns 25 bytes and the case asserts against 26. The parser is right; the fixture is one short. The four SMTP submission cases pass, as does 'Smtp commands 000'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:34980replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Script an IMAP server that behaves badly, and pin what the client refuses

The round-trip in fe2o3_mail/tests/imap_roundtrip.rs proves the happy path against fe2o3's own ImapServer, and cannot pose the cases where this client is either right or quietly compromised: a BYE greeting, a POP3 banner, a literal larger than the client will allocate for, a tag it never sent, a server that hangs up mid-command, and bytes pipelined behind a STARTTLS response, which is RFC 2595 3.1's downgrade attack and had no coverage at all. Twenty-three cases against a scripted server on loopback. Four of them assert what did NOT cross the wire -- the password withheld from LOGINDISABLED and from an unsecurable connection, the bearer token withheld from a server without XOAUTH2, the APPEND body withheld from a mailbox that refused it -- and four read the client's own words back off the wire rather than trust the function that wrote them: the collapsed UID set, .SILENT on every STORE, EXAMINE as the verb, and RETURN (SPECIAL-USE) only where it was offered. The reader gets the case it exists for: a literal spliced mid-line whose payload holds a CRLF, a close paren and a {17} of its own. Until now only the parse function saw that; the reader that assembles the logical line did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:34978replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+A submission conversation, and the permanence tag that never reached a caller

The four submission cases in fe2o3_net/tests/smtp_submit.rs had never run: tests/main.rs gates every case behind one filter string, and that string is "dns", so test_it matched nothing and the harness reported the file green at 0.00s. Twenty-two cases now live in the module itself, where no word elsewhere can switch them off, and they drive the whole conversation against a stand-in provider on loopback rather than construct a config: the order of EHLO/AUTH/ MAIL/RCPT/DATA, AUTH PLAIN's RFC 4616 encoding, the AUTH LOGIN fallback, dot-stuffing on the wire, multi-domain recipients, and four refusals proved by what did NOT cross the wire afterwards. Two of them found a live defect. is_permanent read one error frame, and res! wraps a cause in an Error::Upstream carrying no tags of its own -- so the Permanent tag set on a 5xx in transact was hidden by the single res! between it and every caller. The predicate answered false to every permanent failure there has ever been. fe2o3_steel's subscriber list therefore kept mailing addresses whose servers had refused them outright, and deliver's own re-tagging was inert for the same reason. It now walks the chain, and deliver reads the predicate rather than repeat the shallow check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:34975replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Make the egress allow-list cover every way a vhost reaches outward

validate_egress looped over api_routes only, so an operator who had written an egress_allowed list -- a deliberate statement of which hosts this vhost may reach -- had it bypassed by a ws_route, a proxy_route, or a webhook route in forwarding mode. Three of the four outward reaches were outside a control that claims to cover egress. Not exploitable in the current configuration: every ws and proxy upstream deployed today is 127.0.0.1, which is on the allow-list anyway. Found by reading, not by an incident. The enumeration and the matching are now each in one place -- egress_targets lists what can reach out, egress_permits decides whether an entry permits it -- so a route kind added later is a failing test rather than a second check that disagrees with the first. An entry was also read as host:port at the first colon it contained, which meant a bracketed IPv6 entry could never match its own upstream: an allow-list that reads as populated and permits nothing. The port is now taken from the last colon and only when it parses as one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:34969replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_file: an .odp's styles.xml children go in the order the grammar requires

`office:document-styles` is a SEQUENCE -- font-face-decls, styles, automatic-styles, master-styles -- and the presentation branch opened `office:automatic-styles` before `office:styles`, so every `.odp` came out with those two the wrong way round. `.odt` and `.ods` take neither branch and were always in order, which is why only the deck was wrong. Moving it also merges the two `if slides` blocks into the one they should always have been, since the automatic styles exist only to define the PM1 page layout and dp1 drawing page that the master page immediately below refers to. The test checks all three formats rather than the one that broke, and asserts the deck still HAS all three elements -- a sequence check that passed by finding only `office:styles` would prove nothing.

44 days agor1870400018:34955replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_file: office:mimetype does not belong on office:document-meta

The OASIS grammar defines that attribute in `office-document-attrs`, and the only element referring to those is `office:document` -- the root of the FLAT single-file form, which has no `mimetype` member to carry the fact instead. A package does have one, so on `office:document-meta` the attribute is not merely forbidden, it is redundant. It hit `.odt`, `.ods` and `.odp` together because all three writers share `pkg::meta`, which is the same property that made it one fix. The parameter went with the attribute rather than being left unused. Found by validating against the OpenDocument 1.3 RELAX NG grammar. LibreOffice reads all three files either way.

44 days agor1870400018:34951replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+fe2o3_file: xl/styles.xml carries a <cellStyles> with Excel's Normal

The module header already lists the tables Excel insists on and the schema does not require -- the two named fills, one font, one border, one cellStyleXfs entry -- because Excel's answer to a missing one is a repair prompt that names no reason. `cellStyles` belongs on that list and was not on it. It holds the named style a cell wears when nobody has styled it, and Excel writes `<cellStyle name="Normal" xfId="0" builtinId="0"/>` in every workbook it saves. It goes after `cellXfs`, where CT_Stylesheet's sequence puts it. openpyxl warned "Workbook contains no default style" over every workbook this crate wrote and said nothing over LibreOffice's, which is what pinned the omission on us rather than on the reader. The warning is now gone. The test walks the six required tables in CT_Stylesheet's order rather than merely looking for the new element, since a `cellStyles` before `cellXfs` would be invalid however right its content.

44 days agor1870400018:34944replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_file: two sheets may not share one tab name

`sheet_name` stripped the characters Excel refuses and truncated at 31 and never looked at the names already issued, and `Book::from_doc` names a sheet after the heading above its table. So `## Q1/Q2` and `## Q1:Q2` both became `Q1 Q2`, and any two headings agreeing for 31 characters collided after truncation. Excel refuses a workbook with duplicate tab names, and refuses the FILE rather than the name. The first two rules are properties of a name alone; uniqueness is a property of the SET, and nothing looked at the set. So the whole job moves to `sheet::tab_names(book)`, which both writers now call -- the `.ods` writer had no deduplication at all, and OpenDocument wants distinct table names too. Both formats take the same names, so a sheet answers to one name whichever format a caller is holding, which matters because an edit names its sheet. A collision is disambiguated ` (2)`, which is what Excel does when you copy a sheet, with the head cut back to keep the result inside 31. `Book::from_doc` now keeps the heading WHOLE. Truncating in the neutral model put one format's limit where it does not belong, and threw away the characters that tell two long headings apart before the writer that must tell them apart could see them. Why a reader-based oracle could not find this: LibreOffice and openpyxl each silently RENAME the second sheet, so the file opens and the user merely does not get the tab they asked for. openpyxl's "Title is more than 31 characters" warning on every such workbook turned out to be a symptom of its own repair -- appending a digit pushed the name to 32 -- and it went when the cause did.

44 days agor1870400018:34940replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+fe2o3_file: a slide layout's type must be one of ST_SlideLayoutType's 36

pptx/parts.rs wrote type="titleAndBody" on p:sldLayout. That is the obvious guess and it is not in the enumeration; ECMA-376 Part 1 §19.7.15 lists 36 tokens and the two that mean title-plus-body are `tx` and `obj`. Now `tx`, which is "Title and Text". LibreOffice converted the deck without a murmur, so only the ECMA schemas were ever going to catch this one. The test copies the 36 legal tokens out of the spec rather than reading the writer's own constant: a test that read the constant would have agreed with `titleAndBody` just as readily.

44 days agor1870400018:34920replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+fe2o3_file: drop calcChain.xml when a formula is DESTROYED, not only written

xlsx/edit.rs set the drop flag from `s.formula.is_some()` -- the formulas being written -- so a plain value written over a formula cell left `xl/calcChain.xml` naming a cell that no longer holds an `<f>`. `cell_markup` splices a whole new `<c>` over the old span, so the `<f>` goes with it. ECMA-376 Part 1 §18.6.1 says a `c` in the chain is "a single cell, which shall contain a formula", so this is a spec violation where the already-handled case is only a stale hint, and Excel's answer to a wrong chain is a repair prompt that never names a reason. Emptying the part would not do either: CT_CalcChain requires at least one `c`. The part goes, with its content-type override and its workbook relationship, as it already did for the other direction. Found by the ECMA-376 schemas in dev/verify_ooxml.mjs. No reader on this machine reads the part, so nothing else could have found it. The test grafts a chain onto foreign.xlsx, because LibreOffice writes none and `drop_calc_chain` had therefore never once had a chain to drop -- a removal proved by never being invoked. It asserts the fixture HAS a chain before asserting anything is removed, and asserts the chain SURVIVES a plain write over a plain cell, so the flag cannot be "drop it always" under a narrower name.

44 days agor1870400018:34915replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Shorten one banner in the AVI reader

A trailing hunk from the codec sweep that arrived after its commit had gone: a two-line group banner cut to the 1-3 word form the style asks for, the fields beneath it saying the rest. Comment only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:34897replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Say a thing once in four files of fe2o3_text

The first four files of the text crate's pass, landed on their own because the sweep was stopped part way through the crate and a half-swept crate left dirty is a worse state than either finishing or not starting. 83 doc lines to 65, one stamp each, fences unchanged, code identical by comment-stripped comparison, crate builds. The other 37 files of fe2o3_text are untouched and still on the list, as is unicode/tables/, which is six generated files marked DO NOT EDIT and is excluded from the pass on purpose: hand-editing them would be undone by the next regeneration, so the 145 doc comments they carry want fixing in gen_unicode.rs's emission instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:34895replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

34 operations, since the previous mark · compare with the head
+Say a thing once in the codecs, and keep every clause reference

Fifty-two AI-written files, 4,373 doc lines down to 2,929, and the stamp on each. This crate is from-scratch H.264, H.265, JPEG, PNG, MP4, Matroska and AVI, so the usual rule needed its opposite alongside it: the single most valuable comment in a codec is the one naming the clause of the standard a table or a step comes from, and a mechanical deletion pass is exactly what destroys it. Every specification citation survives. Where one sat on an enum variant or a constant, which the rule always strips, it was demoted to an aligned trailing `//` rather than dropped -- 111 demotions, of which 17 carry a citation, a fixture's provenance or a measured tolerance. The tests are mostly external oracles, and the same reasoning applies harder: a note recording which tool produced a reference value, at what version and with what flags, is unrecoverable once deleted. 34 such blocks kept in place, including ffmpeg's `-noautorotate` in film_posters.rs, "taken from the drawing rather than from the file" in apng_oracle.rs -- the line that says the oracle is not circular -- and "first *intra*, not first shown" in h264_corpus.rs. mp4_oracle.rs kept its whole per-function inventory and lost only its constant block. Three docs were describing code that does not exist: - matroska_frames.rs `gather()` claimed to follow no symbolic link twice. It uses fs::metadata, which follows symlinks, and keeps no visited set. The claim is removed; the recursion is still worth a look. - hevc_tiles.rs `Rgb` was documented as a brightness plane. read_png fills it with three interleaved channels. - raster.rs `square()` was documented as covering the middle four pixels of an 8x8 window. It is a general rectangle constructor called with a dozen coordinate sets in the same module. Code is unchanged, proved by comment-stripped comparison over all 52. Doctest fences 16 before and 16 after. 303 lib tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:34860replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1298 operations, since the previous mark · compare with the head
+Correct the headers that had stopped being true, and the links that went nowhere

The admin dashboard's module header still said SCOPE_DASHBOARD_ADMIN "enables mutations in a future v2 (edit ozone values)". That future arrived: the scope gates the address guard's whitelist and blacklist today, at handler.rs:1699, and the method's own doc was corrected yesterday while the header above it was not. A header is the first thing a reader of a module meets, so it was the last place that claim should have survived. Eight intra-doc links resolved to nothing, found by turning rustdoc::broken_intra_doc_links into a warning and reading it rather than by looking: - cas_o3db.rs twice pointed into `super::dist::o3db_storage`, which is behind the `dist` cargo feature and so is absent from a default build. Now plain text naming the feature, since a link that exists only under a feature flag is worse than no link. - cache.rs wrote "1 [MiB] = 1024^2 bytes", where the brackets were accidental and rustdoc dutifully looked for an item called MiB. - host_sampler.rs pointed at `traffic::TrafficRecorder` and `Server::start` from a module where neither path resolves. - feed.rs pointed at `valid_date`, which lives one module up. - console/publish.rs pointed twice at `DeclareConfig`, which lives in publish::declare. Both crates now emit no unresolved-link warnings at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:33561replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Grade the one file that could not answer the question with a yes or a no

fe2o3_file/src/lib.rs was stamped "written entirely with AI" yesterday and the mark was removed when blame showed the author's own lines in it. It then declared nothing, which is a claim by silence and as wrong as the one it replaced. Blame puts it at 24 of his lines against 14 AI, 36.8%, which is the middle rung: he wrote the opening header and `pub mod tree`, and AI wrote the section on archives -- including the reason `office` lives in this crate rather than beside the document tree, which is that fe2o3_jdat depends on fe2o3_text, so fe2o3_text can never depend on the archive -- and four of the five module declarations. It is the only file in the library carrying a graded declaration. The other 144 mixed files carry none, because a mark is a claim about a whole file and their proportions are not measured one by one. This one is measured, so it can say what it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:33548replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Take the AI's documentation out of the author's own files

A file the author wrote and an AI later added to cannot be swept by the rules that suit a wholly AI-written one: those rules delete a `///` on a struct field, and in these files some of those are his. So the unit here is the line rather than the file. Every line is attributed with `git blame -w -M -C`, using the same classifier as the provenance audit, and only a doc block attributed wholly to AI commits is removed. 1,690 lines across 54 files. Three deliberate conservatisms, because the two errors are not symmetrical -- wrongly keeping an AI doc costs one surplus comment, and wrongly deleting one of his loses a line of his writing: - A block with even one line of his in it is left entirely alone. Eight such blocks were found and none was touched. - A line the classifier cannot place counts as his. - A block carrying a doctest is left whole, as everywhere. Sixty-three. Verified by recording every doc line in these files attributed to him or to nobody -- 1,863 of them -- and confirming after the pass that all 1,863 are still present. Code unchanged by comment-stripped comparison, doctest fence parity holds in all 54 files, and the seven crates build. These files get no stamp. A declaration is a claim about a whole file and his lines are in every one of them. fe2o3_net, fe2o3_crypto, fe2o3_hash and fe2o3_iop_crypto are excluded: the first has a live sans-io refactor in it, and the other three are where a key-agreement design is being worked out this evening, which should follow the code rather than the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:33546replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

386 operations, since the previous mark · compare with the head
+Say a thing once about keys and hashes

Six AI-written files across fe2o3_crypto and fe2o3_hash, 328 doc lines down to 161, and the stamp on each. Swept ahead of their turn because the Ore session is about to write a design note across these two crates and would rather cite line numbers that have already moved than have them move underneath it. The security warnings are the point of the pass here, not its casualty. Wallet::enrol, change_password and remove_by_name each authenticate nobody -- they exist for a host that unlocked at startup and is carrying the master key -- and each now says so in one bold clause instead of five lines of surrounding narrative. unlock keeps the reason its expiry check runs after the wrap decrypts rather than before: an expired admin holding the right password is told it has expired, because once you have proved you hold a credential the system says why it still refused you. SignedCommand::verify keeps that it verifies the signature and nothing else, so a replayed command passes it. sha256.rs was already right and lost almost nothing: the round constants are the first thirty two bits of the fractional parts of the cube roots of the first sixty four primes, and that sentence is why anyone can check the table. It moved from a `///` to a `//`, because the rule keeps facts and not the sigil they arrived in. Its FIPS 180-4 vector provenance is untouched, as is the note in tests/phash.rs recording that an external tool produced each variant. Code is unchanged, proved by comment-stripped comparison. Doctest fences unchanged. fe2o3_hash tests pass, 29 across six binaries. fe2o3_crypto's test harness does not link, and does not at HEAD either: rust-lld reports jent_entropy_collector_alloc and three more jitter-entropy symbols undefined, from pqcrypto-internals' C build. Confirmed pre-existing by restoring all six files to HEAD and rebuilding, then restoring the pass and checking the md5s. The library itself builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:33159replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

163 operations, since the previous mark · compare with the head
+Let an upgrade recover the bytes read past its headers

HttpMessageReader keeps whatever it took off the stream past the end of a message in a private buffer. A keep-alive loop needs no accessor -- the remnant is the front of the next message and the next next() consumes it. A caller that stops reading HTTP and takes the raw socket does: at a protocol upgrade the 101 is the last HTTP on the connection, and the remnant is the front of the next protocol's first message. Dropped, it is invisible almost always, because a client waits for the 101 before it frames anything. On the occasion a client pipelines its first frame, or one TCP segment carries both, the new protocol's stream is missing its first bytes -- and a stream missing its first frame does not fail, it decodes to nonsense. Occasional silent truncation in a byte pipe is the shape of bug nobody can report usefully, so the bytes are made reachable rather than left to each caller to notice they are gone. remnant() borrows, into_remnant() takes. Two tests: an upgrade request with a masked binary frame behind it in the same buffer keeps the frame, and a request with nothing behind it leaves nothing -- the second so an accessor that invented bytes would be caught as well as one that lost them.

44 days agor1870400018:32995replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Put the qualifier last, where the owner's words put it

The five declaration levels are fixed words and all five carry the qualifier at the end: made with no AI, with some AI, with AI, mostly with AI, with AI entirely. The code stamp introduced yesterday wrote the top rung as "Written entirely with AI", which reads better and is a different sentence. It is now "Written with AI entirely" in all 320 stamped files. The scheme's whole claim is that a slug, a mark, a wording and a page say one thing, so a stamp that words the claim differently from the page it links to is the one defect the scheme cannot afford. need2know.ai now serves "written" as the verb for the code medium at every level, so /entirely-ai/code reads "This code was written with AI entirely" and the stamp above matches it exactly. The other five media keep "made". The URL and the slug are unchanged, so any badge or QR code already printed still resolves. Nothing but the stamp line differs in any of the 320 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:32992replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

320 operations, since the previous mark · compare with the head
+Say a thing once in the network layer

Forty AI-written files, 3,115 doc lines down to 2,025, and the stamp on each. The rules are the usual ones: no `///` on an enum variant, a struct field or a constant, no `# Returns`, and nothing that restates the name it sits above. Where a deleted doc carried a fact it became an aligned trailing `//`. This crate is mostly wire contracts, so the exemption did most of the work: the docs that survive say what a peer does with a value, which RFC governs it, or why the code departs from the specification, and the ones that went were re-spelling an identifier. http/pct.rs came through with its doc count unchanged, because every comment in it already stated a rule the signature does not carry. jose.rs keeps the `///` on TEST_P256_PKCS8 and TEST_P256_THUMBPRINT_B64, which record the openssl and python3 derivations of those values -- where a fixture's expected value came from is the one thing a reader cannot recover. Three comments were found to be describing code that does not exist: - dkim.rs: the doc for `sign` had been glued onto the front of `signing_input`'s, running straight on with no break, while `sign` itself had none. Split, and each now sits on its own function. - dkim.rs: `DkimSigner` was documented as owning a live Ed25519KeyPair. It owns a DkimKey, which has been Ed25519 or RSA since RSA was added. - dns_resolver.rs: `lookup_a` claimed to return answers after CNAME chasing. It chases nothing, and an inline comment eight lines into the parser says so. The claim is gone; the resolver's handling of a CNAME is still worth a look, but that is code and not this pass. Two more are documented rather than changed, both in acme/: a certificate and its key are written by two separate atomic writes, so an interruption between them leaves a new certificate beside the old key and the both-or-nothing check does not see it; and poll_until_ready returns on Valid as well as Ready, which is right but not what its name says. Code is unchanged, proved by stripping every comment from both the committed and working copies with a lexer that understands raw strings, byte and char literals and lifetimes. Doctest fences 20 before, 20 after. 296 lib tests, 9 integration tests and 4 doctests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:32671replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1279 operations, since the previous mark · compare with the head
+A peer's own close code is a goodbye, not a read error

RFC 6455 §7.4.2 leaves 3000-4999 to the application, and TryFrom refused the whole range -- so a caller whose protocol lives there had its peer's close frame turned into a read error. Private(u16) decodes it and maps back unchanged. The range is not widened to "anything": the tests assert that 1004, 1014, 2999, 5000 and 65535 are still refused, because a peer's typo should not arrive as a meaning. Written by a subagent of mine at 15:13 and left uncommitted, which is how it came to sit in a shared tree for five hours while I told two peers it was not mine. It was found by a third session reading the subagent transcripts I had not read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:31391replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+window() returns the rectangle it was asked for, and now says so

The doc claimed the result was clipped to what the sheet holds; it is not, and should not be. Its one caller draws a grid in the browser, and a grid that shrank whenever the far corner was empty would be worse than one with empty cells in it. So the sentence was the defect, not the loop. Found by the documentation-sweep session reading it cold, which is the third comment today that described behaviour the code does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:31386replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Take back the documentation retrofitted onto the database

The other half of 600e8e9, by the same method as Shield: remove the lines git blame still attributes to it that are `///`, leave everything else. 546 lines across 18 files, and none of them was a doctest. Twelve of the removed blocks sat on functions and were read before going. Not one carried a fact its own body did not already state four lines below: choose_cbot_select's doc described taking the low sixteen bits modulo the zone count and the next sixteen modulo the pool count, above a body that is those two lines of arithmetic and nothing else, and clear_all_values explained that locations survive above a loop that sets `*val = None` and touches nothing else. The author's own documentation in these files is untouched, including the worked forward-and-reverse-map diagram in data/cache.rs, which is the kind of thing a doc comment is for and which no signature could carry. Code is unchanged, proved by comment-stripped comparison. Lib tests 23 pass, as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:31384replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

438 operations, since the previous mark · compare with the head
+Take back the documentation retrofitted onto Shield

Commit 600e8e9 added 975 doc comments across fe2o3_shield and fe2o3_o3db_sync in one pass, onto two crates that had deliberately been left sparse. In srv/cfg.rs it put a `///` above every field of ServerConfig, each restating a `//` banner the author had written in 2024 and the field name beside it; in srv/constant.rs it did the same to a table whose alignment and trailing `// 30 min`, `// 3 days` notes had said all that needed saying. This removes the lines git blame still attributes to that commit and that are `///`, in the Shield half. Blame rather than a revert, so that a line edited since is left alone as somebody's considered change, and so that the 75 plain `//` comments and 13 lines of code from the same commit stay. A doc block carrying a doctest is kept whole. Fourteen of the removed blocks sat on functions and were read individually before going. Three looked like they carried a fact worth keeping, and on inspection none did: get_trusted_seeds states its minimum in the `if` on its first line, sig_state's three strings are the three match arms below it, and new_db's cipher is named by the type aliases in its own signature. A doc repeating a value defined elsewhere is a second place for it to go stale, which is how five docs in Steel came to contradict their code. Code is unchanged, proved by stripping every comment from both sides with a lexer that understands raw strings, byte and char literals and lifetimes, and comparing what is left. Also ignore .claude/worktrees/, 104 MB of agent checkouts sitting untracked in the repository root, one blanket `git add` away from being committed into the tree they are a copy of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNqYLm5UQGxLZ7TJ3vFSez

44 days agor1870400018:30945replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

329 operations, since the previous mark · compare with the head
+Carry an operation a relay cannot read

A third entry kind: the record header in clear beside the whole tagged entry encrypted. The header is what a session needs and the whole of what it needs -- an identifier and its parents -- so a carrier can walk frontiers, reconcile sketches and hand operations on while being unable to read a byte of what they say. What goes under the cipher is the entry's own tagged form, so a sealed envelope unveils to a sealed envelope and the signature made before the veil is the signature checked after it. The clear header is compared with the signed one on the way out and a disagreement is refused by name, naming the signed copy as the one to believe. peek refuses a veiled entry rather than answering with a stand-in, since every caller of peek asks in order to read an operation. Veiling does not nest, and the body is written under a 64-bit length only, so one veiled entry has one spelling and one digest. The format version does not move. A kind is a separate axis from the vocabulary: bumping it would shift the version byte in two frozen arrays for a change that alters no plain record, and would make every public repository's fresh segments unreadable to an older build for a form only private ones use. The relay publishes the kinds it speaks instead, which is the capability a caller actually needs to know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

44 days agor1870400018:30615replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

36 operations, since the previous mark · compare with the head
+Say a thing once in Steel, and correct what had stopped being true

5,644 doc lines to 3,576 across the 48 files with no surviving human line. The cut is smaller in proportion than elsewhere because much of what Steel documents is a wire contract or a security property: RFC citations in cache.rs, what a route serves unauthenticated, what a credentials endpoint will and will not disclose, and the config fields where a wrong value is not a typo but an exposure -- the egress allow-list, trusted_proxies, site_admins, the rate limits. Those keep their documentation. Five docs had stopped being true of their code and are corrected, each checked against the body first. can_admin_dashboard said it gated nothing because the dashboard was read-only; it gates the address-guard mutation today. render_traffic said the view had no auto-refresh; the page embeds it. The signed-login TTL said it matched the passphrase flow at one hour, where that flow is thirty minutes. A DKIM doc still described a single signer above a vector of them. An alert doc said the same thing twice. Ten doc blocks were sitting on the wrong item -- each carrying the prose of the function above it, which had been left undocumented. They are moved back, or deleted where the stray half only restated a name. One field doc survives the never-document-a-field rule, because it carries a fenced openssl command and the never-delete-a-doctest rule outranks it. That is what holds the fence count at 32, in and out. Left alone, and wanted: the module header of srv/admin/mod.rs still says the dashboard scope is for a future v2, which is the same claim corrected above. Headers were out of scope for this pass. Verified independently of the agent that did the work, which had to repair four slips of its own along the way: every comment stripped from both revisions, string- and char-literal aware, and what remained compared. Code identical in all 78 files of the crate. The library builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:30578replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2026 operations, since the previous mark · compare with the head
+Say a thing once in the database, and keep what the format depends on

fe2o3_o3db_sync is half the author's code and half not, so only the 42 files with no surviving human line were touched or marked. The other 60 were left alone entirely, including their own excess. 1,324 doc lines to 641. What survives is what a second implementation would have to agree with: the on-disk shapes, the key encodings, the gear seed whose change would orphan every content address, the IBLT hash count both ends of anti-entropy must match, XOR distance and bucket semantics, cohort seed derivation, and the durability and ordering promises. Three docs were wrong about their own code and are corrected. ViewId said the crate only ever uses view 1; on_timeout increments it and on_new_view drives the change. Cas::put_bytes said it chunks its argument; it stores it whole, as one chunk. A test helper documented three return values against a four-tuple. Several intra-doc paths were stale from the lift of standalone crates into this one -- crate::config, crate::cohort, crate::placement, all now under crate::dist -- and one pointed at a field that does not exist. Left alone and worth a pass: the module headers carry the same stale paths, plus references to a fe2o3_hotstuff crate that is now src/dist/hotstuff and two headers calling a module a crate. Headers were out of scope here, so they will show as broken_intra_doc_links until somebody takes them. Verified by stripping every comment from both revisions, string- and char-literal aware: code identical in all 102 files, and the 28 doctest fences are untouched. The library tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:28551replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

732 operations, since the previous mark · compare with the head
+Count Islamic years by the cycle the calendar is defined on

Correcting the epoch alone made things worse, which is what pointed at the real defect. The epoch was 1948439, the astronomical one, while every sentence in the file and the copy in calendar.rs both name 16 July 622 Julian, which is 1948440. Setting it to 1948440 on its own moved 1 Muharram 1445 from 19 July 2023, the right answer, to the 20th. Two errors had been cancelling. The second was the arithmetic. Year starts were counted as floor((year - 1) * 354.36708), a mean year length, which drifts a day at a time and disagrees with is_islamic_leap_year twenty lines below -- that decides the length of Dhu al-Hijjah from the thirty-year cycle, so month lengths followed the cycle while year starts followed a float. Years are now counted the same way: 11 leap years of 355 days and 19 common ones of 354, which is 10,631 days a cycle. Months alternate 30 and 29 from Muharram, so the months before one hold 29 each plus one more for every odd-numbered one. The floats that remain only seed the search loops in jdn_to_islamic, which correct themselves against islamic_to_jdn, so they are estimates and stay. A year before 1 is now refused rather than answered with nonsense. Checked against dates outside this crate: 1 Muharram of years 1, 1444 and 1445 come out as 19 July 622, 30 July 2022 and 19 July 2023, which are the announced dates. 1446 gives 8 July 2024 against an announced 7 July -- the one-day difference between an arithmetic calendar and a sighting, which is the tabular calendar working rather than failing. Two tests were wrong and are corrected. test_islamic_epoch expected 16 July 622 Gregorian; the epoch is 16 July Julian, which is 19 July Gregorian, and the assertion is now exact rather than a three-day window -- that window is why a one-day epoch error survived. test_days_in_islamic_month had 1445 and 1446 the wrong way round: 1445 sits at cycle position 5 and is a leap year, 1446 at 6 and is not. All 7 Islamic tests pass, against 5 before. The crate is 245 passing and 9 failing, unchanged by this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:27818replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+The leap second was the night before, and the Islamic epoch is off by one

Two findings from the documentation pass, one fixed and one deliberately not. test_leap_second_detection asserted that 2017-01-01 23:59 carries a leap second. It does not. The leap second the table records as "2017-01-01" was inserted at the end of the preceding day, as 2016-12-31T23:59:60Z, and that instant is 1483228800 -- exactly the entry the table holds. The table entry is the moment the new TAI-UTC offset takes effect; the date carrying the sixtieth second is the day before it. So validate_leap_second was right and the test named the wrong day. Checked against the epoch arithmetic rather than reasoned: 2016-12-31T23:59:59Z is 1483228799, and the leap second follows it. The Islamic epoch is a decision rather than a typo, so only its documentation is corrected here. The doc had the two calendars the wrong way round: 1 Muharram 1 AH is 16 July 622 in the Julian calendar, which is 19 July 622 proleptic Gregorian, not the reverse. Underneath it, ISLAMIC_EPOCH_JDN is 1948439, which is 15 July 622 Julian -- the astronomical epoch -- while every sentence in the file names 16 July 622, the civil epoch, which is 1948440. Both conventions are in use. Correcting the constant moves every date this calendar converts by a day, and test_islamic_epoch expects a third answer again, so the disagreement is recorded where the constant is and left for a decision. 243 tests pass, 11 fail, against 242 and 12 before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:27810replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+The CalClock port says a great deal less, and means the same

fe2o3_datime is the crate the old documentation rule hurt most, and the largest single cut of the pass: 4,971 doc lines to 877, and a need2know mark on all 122 files. Every one of them was verified to hold no surviving human-written line first, so the claim is true throughout. It documented very nearly every public item, because CLAUDE.md said to and its worked example showed how -- an `# Arguments` block naming year, month and day, and a `# Returns` restating `Outcome<Self>`, on a constructor whose parameters say everything. That example is gone from CLAUDE.md now, and this crate is what it produced. Ninety files had no module header at all and nineteen had one written as `///`, attached to the first `use` statement rather than to the module. Those nineteen are now `//!` at the top of their file where they belong, which is a correction rather than a cut. Where a file's opening prose was really module-level and had been living on an over-long struct doc, it was moved up rather than deleted. Kept whole: the pattern-syntax table and the accepted-input list, which are token meanings and a parsing contract; the calendar lore, which is the valuable part of this crate; and every doctest, 96 fences in and 96 out. Demoted rather than dropped, because a name does not carry them: JavaTime being milliseconds, IndexKey::Timestamp being Unix milliseconds, DayOfWeek counting Monday as one, the RFC 9557 precision characters, the cron field ranges, and the arithmetic split where the Self forms error on overflow while the _long forms saturate. Four claims were corrected rather than preserved, each checked against the body first: an O(1) access time on a cache that is O(n) by construction, and three rates documented as percentages that return a fraction. Left alone, and worth a look: the enum in business_day_engine.rs whose variant docs each sat one variant below the text that fits them; is_valid_second, which says it allows leap seconds and rejects sixty while the rest of the crate treats sixty as valid; and the fenced examples throughout, which the old ?-to-ok! sweep corrupted into things like CalClockZone::utc()res!(). They are ignored, so they never ran, and they are not comments to cut. Verified by stripping every comment from both revisions, string- and char-literal aware: code identical in all 122 files. 242 tests pass and 12 fail, which is exactly what HEAD does -- checked by stashing this pass and running it again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:27806replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2656 operations, since the previous mark · compare with the head
+Deck writing behind a feature, so the cost of carrying it can be measured

`deck-write`, on by default, so nothing changes for any caller that does not ask. It covers `pptx::write`, `pptx::parts` and `odf::slides::write` -- the slide master, layout and theme a PresentationML package cannot open without, plus the OpenDocument presentation writer. READING a deck is unconditional and stays so: a reading view offers six formats and dropping one would change what a user already has. Both writers keep one signature either way. Built without the feature the call returns an error naming the feature rather than an empty file, so a caller does not change and a person is not handed a `.pptx` with nothing in it. Measured on Daimond's browser bundle, both builds from one tree state and the "on" build reproduced byte for byte: 3,403,232 bytes with, 3,346,025 without -- 57,207 bytes, 1.68% of the bundle, 12 functions and 44,065 of them in the code section. Not a decision to ship it off; a number to decide with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:25149replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

25 operations, since the previous mark · compare with the head
+Say a thing once, in fe2o3_file, and leave the author's own files unmarked

The second crate through the documentation pass. 1,281 doc lines to 731, and a need2know mark on the 41 files that earned one. Four did not. src/tree.rs, tests/tree.rs and tests/main.rs date from the 2024-12-25 import, carry no doc comments at all across 285 lines, and are written in the author's hand; marking them would be a false claim about who wrote them, and they were left untouched entirely. src/lib.rs is the awkward one: it dates from 2024 too, and although its header reads as AI-drafted and its second half was added in 2026, git blame -w -M -C puts it at 23 human lines against 16 AI. A file that is 59% the author's cannot carry "written entirely with AI", so the mark came off again. It wants a graded declaration rather than this one. Every other stamped file in this crate, and all 27 in fe2o3_ore, has no surviving human line at all, so the claim is true where it is made. The wire-contract exemption did most of the judging here. A constant whose value is fixed by a specification and read back by another program keeps its documentation: the EXIF tag numbers, the ZIP date encoding, OLE_MAGIC, the OOXML numbering identifiers that must resolve across two parts. What does not survive is a doc that merely re-spells the constant it sits on -- the twenty-five namespace, relationship and content-type strings in office/opc.rs now carry one banner between them, which says the thing the twenty-five sentences were each saying a quarter of. Kept: every doctest, 16 fences in and 16 out; the format lore that cost somebody a debugging session; and the glob precedence rules, which are the kind of fact no reader derives. Two defects found and deliberately not fixed, being content rather than cuts. Sheet::window is documented as clipping to what the sheet holds and does not clip. Three docs named parameters or behaviour that do not exist, and trimming their restating first lines removed the error as a side effect rather than by intent. Verified by stripping every comment from both revisions, string- and char-literal aware, and comparing what remained: identical in all 45 files. The suite and both doctests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:25123replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

576 operations, since the previous mark · compare with the head
+Say a thing once, where a reader could not have worked it out

fe2o3_ore is the first crate through the documentation pass, and the first to carry a declaration of who wrote it. Every file gains one need2know mark at the foot of its module header, linking to need2know.ai/entirely-ai/code. The claim is true of this crate: no line of it predates the AI era, and nothing in any pre-2025 snapshot matches it. The cut is 4,219 doc lines to 2,455, and it is deletion rather than shortening. Per comment the length was already right -- the AI writes roughly what the author writes, sometimes shorter. The volume was coverage. Measured on the 2024-12-25 tree the author documents 15.2% of public functions, 1.3% of struct fields, 0.4% of constants and none at all of 1,688 enum variants; this crate documented nearly everything, because CLAUDE.md told it to. That rule changed today, so what follows it is the practice rather than the aspiration. So: no doc on an enum variant, a struct field or a constant, and no `# Returns` on anything. Where a deleted doc carried a fact, it moved rather than went -- to an aligned trailing comment, to a banner above a group, or to a plain comment block where the reason was too long for a line. Constants follow the author's own ladder, which reserves a doc comment for a value that is a contract with a history. Kept whole: the module headers, which are good; the inline comments inside functions, which the author writes more of than the AI does; the test docs that record why a case is an honest loss rather than a defect; and every doctest. Left exactly as they stand, because another session owns them and is about to add a third entry kind: KIND_BARE, KIND_SEALED, VERSION, VERSION_MIN and the Entry variants that name them. Verified by stripping every comment from both revisions, string- and char-literal aware, and comparing what remained: identical in all 27 files. 339 tests and 4 doctests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:24546replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1586 operations, since the previous mark · compare with the head
+Put the editors in front of LibreOffice, and it found the one that mattered

Eleven tests over foreign fixtures, and an example that hands a real reader a file this crate edited. Every fixture is one LibreOffice wrote, because the property being tested is that an edit leaves a STRANGER'S file intact and a file this crate wrote has none of the constructs that would be lost. WHAT THE ORACLE FOUND, and what no self-test could have. The first .ods cell writer put a cell's displayed text in as bare character data rather than in a `<text:p>`. A numeric cell still showed, because it carries `office:value` -- so the round trip through this crate's own reader was green. A STRING cell showed as EMPTY, because a string cell's value IS its paragraph. The mistake therefore lost exactly the cells it is hardest to notice losing, and it took a second implementation's opinion to see it at all. That is now the second session running in which LibreOffice has caught a data-loss bug in ODF output that reading our own output called correct. The tests are written to assert the property and not the outcome: * A replacement over four runs checks FIRST that the phrase does not stand in the part's XML as one string. Without that, the test would pass on a fixture that made the join unnecessary and the join could rot unnoticed. * An edit's preservation is checked on the compressed bytes of every other member, not their content. A member rebuilt with the same content and another compression level is a member that was not copied, and copying is the whole claim. * A formula's text is asserted, out and back. Presence was what the earlier test checked, and presence is what let the bracketing asymmetry sit between two green tests. * A cell written into a gap checks its NEIGHBOURS on both sides and every other row, since a mishandled repeat run moves values rather than losing them. * `typed` is checked on what it must NOT convert as much as on what it must. `examples/edit_office.rs` takes a file, a list of edits and an output path, so `soffice --headless --convert-to` can be pointed at the result. A test that reads back what this crate wrote proves that this crate agrees with itself, which is worth very little for a format whose whole purpose is to be opened by somebody else's program. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:22959replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Cells written into a spreadsheet that already exists

The other half of the third verb, and it is not find-and-replace: a spreadsheet's unit is a cell, so the argument is a reference and a value. Both formats, answering the same way, which is the property the tests assert rather than testing each in isolation and letting them drift. A SHEET IS SPARSE, SO A WRITE IS AN INSERTION AND NOT AN ASSIGNMENT. There is no array to index. In a .xlsx a row may be absent and `<c r="D3">` may follow `<c r="A3">` with nothing between, so writing B3 means finding where B3 WOULD go: among the cells of row 3 if that row exists, and as a new row in the right place among the rows if it does not. A writer that appended would put the value in the wrong column, or the wrong row, and the file would still look like a file. .ods states the same problem in its own dialect and states it harder. A run of identical cells is written once as `table:number-columns-repeated="8"`, and a run of identical rows the same way, so writing C4 means finding which run covers it and SPLITTING that run into the part before, the cell, and the part after. Left alone, every value to the right of the edit moves one column -- a corruption that looks like a spreadsheet. LibreOffice writes exactly this shape for any sheet with a gap in it, so it is the ordinary case and not the awkward one. TWO PARTS OF A .xlsx ARE REWRITTEN BESIDES THE CELL AND BOTH HAVE TO BE. `<dimension>` says the rectangle the sheet occupies and is widened, since a cell outside it is one some readers do not draw. `xl/calcChain.xml` is Excel's record of what order to recalculate in and it names cells by position, so a formula written into a cell the chain does not know makes the chain wrong -- and Excel's answer to a wrong chain is a repair prompt, which "fixes" the file and never says why. The part is deleted whenever a formula is written, with its content-type override and its relationship, and Excel rebuilds it. Nothing recalculates, here as everywhere: a written formula goes in with no cached value and the reader works it out on open. Leaving the value that was there would leave the value of the OLD formula, and a cell showing a number that does not follow from the expression above it is worse than one showing nothing. TWO WRITES TO ONE CELL IN ONE CALL ARE REFUSED, and before this the two formats disagreed about it in the worst possible way: the .xlsx path failed with an internal overlapping-splice error and the .ods path silently took the first and dropped the second. There is no order for them to be applied in -- nothing here reads a cell it has written -- so "the last one wins" would be a rule about how a caller happened to build a list. Both now refuse, naming the cell. AND ONE READER BUG, WHICH TWO PASSING TESTS HAD BEEN HIDING BETWEEN THEM. `odf::sheet::write` brackets a formula's references as OpenFormula requires and nothing anywhere took the brackets off again, so a formula written by this crate read back as `[.B2]*[.C2]` while the same workbook as a .xlsx read back as `B2*C2`. Neither existing test was wrong: the writer's checks the bytes it produces, the reader's checks that a formula came back AT ALL. There was simply no test that went out and back. `plain` is the inverse of `openformula` and the reader now applies it. Note what could NOT have found this -- LibreOffice, which is perfectly happy with the file, because the file was correct and the reader was not. The neutral sheet gains the three functions both writers needed. `typed` is the rule a person meets when they type into a cell, and its one subtlety is what it REFUSES: a string is a number only where it is exactly how that number prints, so `007`, `+3`, `1,000` and ` 4 ` stay text. A rule that parsed anything parseable would silently renumber a column of part numbers. `stored` is the shortest text that reads back as the same f64. `Book::from_doc` is the counterpart of `Deck::from_doc`: one sheet per table in a document, named by the heading above it, so a caller that can produce Markdown can produce a spreadsheet without producing a file format. A heading is cleaned of the seven characters Excel refuses in a tab name and cut to 31, because Excel refuses the whole FILE over a name and not just the name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:22952replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+The third verb: editing a document somebody else wrote

Creating a document and reading one both go through the neutral tree in fe2o3_text::doc. EDITING one must never be made to, and the reason is the tree's own argument for existing: it deliberately cannot carry markup it has no node for. That is exactly right for the first two verbs and exactly wrong for the third, because everything the tree cannot represent is everything an edit through it would silently destroy -- the comments, the bookmarks, the tracked changes, the content controls, the custom XML, the theme, the tab stops. Read to Markdown, edit the Markdown, write a fresh file, and what comes out opens, looks about right, and has lost all of it. The person who finds out is not the user; it is the colleague they sent it to. So an edit splices. `office::edit` holds what the two prose vocabularies share, and the shape of it is one observation: THE TEXT A DOCUMENT HOLDS IS NOT IN ONE PLACE. A sentence in a .docx is spread over as many `<w:t>` runs as the writer's formatting made -- a bold word, a spell-check mark, a bookmark or a language change splits one -- and an .odt spreads it over character data, `<text:span>` and `<text:s>`. A find that looked inside one element at a time would miss every phrase a writer had touched, which is most of the interesting ones. A paragraph is therefore a GROUP of pieces, its text is their concatenation, a match is found in the concatenation, and the replacement is pushed back down onto the pieces it covered: whole into the piece holding the START of the match, so it keeps that run's formatting, and removed from the pieces after it. Which is what a person doing it by hand would do. Every changed piece becomes one Splice; zip copies every member nobody touched. A document edited here differs from the one that arrived in exactly the runs that were edited, and the test that says so compares the COMPRESSED BYTES of every other member rather than their content -- two members can hold the same content and different bytes, and it is the bytes a colleague's reader parses. AN UNMATCHED `find` IS AN ERROR NAMING THE STRING, and nothing is written. A silent no-op is the failure this is written against: a caller told "the document was edited" has no way to discover that one of its four replacements did nothing, and will report the document as changed to somebody who then sends it. An `nth` past the end is refused the same way and says how many there are. Two whitespace rules are not optional, and both are the format telling you it will change your text if you do not obey it. A `<w:t>` needs `xml:space="preserve"` wherever the replacement has whitespace at an end. OpenDocument collapses a literal run of two or more spaces, and a space at either end of a run, so a replacement carrying one goes out as `<text:s>` -- and `<text:s>` is also read as text on the way IN, or `Q1 2026` would report as absent from a document holding `Q1<text:s/>2026`, which is what a person typed. Only the body is searched. A phrase in a header, a footer or a footnote reports as absent rather than being changed in one of two places, and an absence that names the string is a caller's cue to look. Deck editing is deliberately absent, and the refusal gives the reason rather than saying "unsupported": a slide is a position on a canvas, so changing words without knowing the geometry puts text over other text. A model told a thing is impossible stops asking; one told nothing tries again. `opc` gains the package navigation the readers each had a private copy of -- dir_of, resolve, rels_of, main_part -- so an editor finds the document part the way the package says rather than guessing at `word/document.xml`, which is a convention and not a rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRT571VteeTte8KGUNFnHx

44 days agor1870400018:22938replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+The three OpenDocument formats, and five bugs a foreign reader found

.odt, .ods and .odp: written from the same three neutral models the Microsoft side uses, and read back into them. Simpler in one specific way -- there are no relationship parts, so the whole class of bug where a body names an id nothing declares cannot occur -- and harder in another, since one content.xml holds every sheet or slide. FIVE THINGS WERE WRONG AND EVERY ONE WAS FOUND BY LIBREOFFICE, not by a test of ours reading our own output: * `table:formula="of:=B2*C2"` is not OpenFormula. A reference must be bracketed -- `[.B2]*[.C2]` -- and written the other way LibreOffice cannot parse it, RECALCULATES, and writes Err:510 over the stored value. A wrong formula does not merely fail; it destroys the number. * And the `of:` prefix must be BOUND, to `urn:oasis:names:tc:opendocument:xmlns:of:1.2` -- not the plausible `...formula:1.0`. Unbound, every formula in the file fails the same destructive way. * A LEADING space is dropped by every reader, so all of a leading run has to go out as `<text:s/>`. Four spaces of indentation became three, in every line of every listing. * A style name a document does not DEFINE is dropped, span and all, so every bold word written here arrived as plain text. * And a reader must resolve styles by their PROPERTIES. LibreOffice names them `T1`, `P2`, `L3`; matching on the name finds this crate's own output and nothing else's. It also writes level TEN of a bullet list as a number, so asking whether ANY level is numbered turned every bulleted list in a foreign document into a numbered one. `mimetype` is first and stored in all three, and the test for it now has teeth: the obvious check is VACUOUS, because `mimetype` is the first member these writers add and comes out first whether `set_first` or `set` was called. The property is therefore checked on an archive that already has members, where swapping the call goes red. An interior run of empty cells or rows is a GAP and is expanded; only a trailing run is padding. Collapsing both put the total row two rows early. Known and said rather than quietly imperfect: an .odp's frames are not true placeholders, so a reader that re-saves the deck drops `presentation:class`. The words, order, nesting and positions survive and it renders correctly; the outline view does not. The reader is built so it does not matter -- where no frame claims the title, the first is taken as it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

44 days agor1870400018:22924replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

24 operations, since the previous mark · compare with the head
+Create and read a .pptx, and name an OpenDocument from its own bytes

A neutral deck -- slides of a title and bullets -- with a PresentationML writer and reader over it. Create, read and show; NOT deck editing. A slide is a position on a canvas, and an edit that changed the words without knowing the geometry would put text over the top of other text: a failure a reader sees and an editor cannot check for. The heaviest skeleton of the three, and structurally so. A .docx opens with four parts and a .xlsx with six; a .pptx needs a slide master, a slide layout and a theme before it can hold one slide, and the theme must carry three fill styles, three line styles, three effect styles and three background fills whether or not anything uses them. A file with any link missing gets a repair prompt naming neither the part nor the reason. LibreOffice converts the result to PDF and to ODP without one. Slide ORDER lives in p:sldIdLst and not in the file names: slide10.xml sorts before slide2.xml and is the ninth slide. The fixture's slide relationships start at rId3, because the master and the theme took the first two, so the red run for that break deals the master's own placeholder out as a slide -- "Click to edit the title text format". `Deck::from_doc` splits at EVERY heading. It split at the shallowest first, borrowing `Doc::top_heading`'s rule, which is right for finding a title and wrong for ending a slide: a document written `# Title` then `## Section` twice came out as one slide holding everything. And `Media` no longer collapses the four OpenDocument kinds into one, so a panel can tell a text document from a spreadsheet. The kind is read from the archive's own `mimetype` member rather than from the filename, which is not a guess: OpenDocument REQUIRES that member first and stored uncompressed for exactly this purpose, and EPUB borrowed the rule. A .odt somebody renamed is still a .odt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

44 days agor1870400018:22899replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+Say which language a file is, rather than assuming Rust

An unrecognised extension fell through unwrap_or("rust"), so naming a .css or .html file explicitly parsed it as Rust and rewrote it, at exit zero and with no warning. The Rust lexer reads the opening quote of 'Oxanium' as a lifetime, so font names gained a trailing space and the urls around them were broken apart. A directory walk skipped those files already; only an explicit path reached this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:22866replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Keep every comment through a format, and refuse the file when it cannot

The annealer was deleting comments and reporting success. A six-comment probe came back with two: a trailing comment on a match arm was moved onto the next arm, where it silently came to describe different code, and comments before a closing brace, above a doc comment, and at the end of a file were dropped outright. Trailing comments are the house idiom, so this hit exactly what CLAUDE.md asks people to write. The token stream survived every time, which is why nothing caught it: plain comments are trivia hanging off the following token, and the semantic test's sig() compared tokens alone. It guaranteed what it measured and no more. sig() now lifts comments into the compared sequence, which turned a passing test into 282 failing files. Comments are carried through each scan that stops at a closing delimiter and does not keep it -- fn bodies, struct fields, enum variants, match arms -- and across the parse, which reads EOF as a stop condition and so never sees a comment on the last line. A comment written after a field's comma belongs to the field it follows, not to the one the lexer attached it to. A body carrying a comment can no longer be collapsed onto one line, because the flat path emits no trivia. Where a layout path still cannot carry a comment, format_rust now compares the comments in its own output against the input and returns an error. Twenty-two files decline rather than lose a line. Declining is a worse formatter and a better tool. Two other corruptions found while testing. Byte and C string literals were split from their prefix -- b"ORE1" became b "ORE1", br#".."# became br #".."# -- which does not compile, and there are 674 of them across 53 files in ore, sbj, steel and datime alone. And four slashes were read as a doc comment, so a //// line moved onto the item below it. Alignment was measured as sum(len) + n - 1, a phantom space per token gap the renderer never emits, so any match whose patterns differ in token count came out crooked: Some(v) is seven columns, not ten. The CLAUDE.md example only looked right because its arms all have the same token count. Widths are now measured as the renderer will emit them. Output also gains the trailing newline every file should end with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WD9whjGQMVeZXPJnJhvJmS

44 days agor1870400018:22864replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

101 operations, since the previous mark · compare with the head
+Do not let a backslash smuggle a host past the URL allowlist

The check for an off-site destination looked for a leading "//" and let "\\evil.example/x" through. The URL standard's relative states treat a backslash as a solidus for every special scheme, so a browser on an https page follows it off-site exactly as it follows "//": the allowlist was reading a different grammar from the one that resolves the link. The cleaned string now maps backslash to solidus before the test, so "\/host", "/\host" and a tab-prefixed variant go the same way, while C:\Windows stays refused and ordinary relative paths still pass. Found while making a forge render a README somebody else wrote, which is the first time this crate's policy stood between a stranger's prose and a page served under our own name. retarget comes with it, for the same caller and the same reason: a site publishing prose it did not write has to send every destination through its own judgement, and dropping a link while keeping its words is what the policy already does for a scheme it refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

44 days agor1870400018:22762replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Read and write a .xlsx, and never recalculate a formula

A neutral spreadsheet -- Book, Sheet, Cell, Value, and the A1 addressing a person types -- with a SpreadsheetML reader and writer over it. THE STORED VALUE IS THE VALUE. A formula cell holds two things, the formula and what the last calculation left beside it, and nothing here recomputes the second. That is the correct answer twice over: the stored value is the number the person who wrote the file SAW, and recalculating would break byte-identity the moment a volatile function is present -- NOW, TODAY, RAND, RANDBETWEEN -- so a file opened and saved with no edit would differ from itself and the check that exists to catch a damaging edit would fire on a healthy one instead. A gate that cries wolf is a gate somebody turns off. Three things about this format are traps and each is the kind of wrong that looks right. A shared string is an INDEX, so a reader that takes the cell at face value returns small integers where the names were. A date is a number and the only thing making it a date is the number format its style points at, through two hops, and it is usually a format the author defined rather than a built-in. And a cell is not where its position says: rows and cells are sparse and carry their own addresses, so a reader that pushes onto the end of a row puts everything after a gap one column out. All three are proved RED against the fixture: Number(0.0) where Text("Region") should be, Number(46095.0) where Date("2026-03-14") should be, and SUM(D2:D3) landing in the wrong column. The fixture is LibreOffice's own output -- it was handed a spreadsheet this crate wrote and asked to save its own -- so the intent is known and every byte of the encoding is somebody else's. It carries a custom numFmt of `General` alongside the date one, which is exactly the case that catches a reader guessing from format ids. The ceiling is stated rather than streamed past, and the reason is written down: streaming would need a second XML reader of the kind that hands over events rather than a tree, and the tree is what the editing path needs for its spans. One reader that refuses honestly above a stated ceiling beats two that disagree about what a document says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22756replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+A picture in a foreign document is counted, and the prose survives it

The counting was tested on a hand-made Reading, which proves the sentence and not the seeing. This reads a .docx LibreOffice wrote with a picture in it and checks all three things at once: the drawing is counted as one image, the prose BEFORE it is there, and the prose AFTER it is there -- a reader that stopped at the drawing would report exactly one image and lose half the document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22734replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Say what undoes an operation, once, where both callers can read it

A revert is written by the tool today and will be offered by the forge, and two tables of what an inverse is are two tables that can disagree about a history they both write into. So the inverse table, the refusals and their sentences, and the anchor a restoring copy binds by all live here. The anchor is the load-bearing one. A copy binds after the last byte of what it restores, and that convention is what lets `who` read the link back from a restored run to whoever first wrote it. Spelled any other way by any other caller, attribution stops working while both sides round-trip against themselves. The final arm of `undoing` is an error rather than a default, so an operation added later fails loudly instead of being quietly undoable by nothing. FromStr for OpId because an identifier is the one thing in this vocabulary a person types, and a reader written per caller accepts a different set of spellings per caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

45 days agor1870400018:22729replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Pin that a share's consent bit is its author's and not its carrier's

Two tests for one claim, at the two levels it has to hold at. In the payload: setting `code` changes the bytes the address is taken over, and the tampered bytes are refused on the way in. In the artefact: a signed share of data alone, with the bit set afterwards by whatever carried it, fails at the address -- so a relay that wanted to add a consent claim would have to forge a signature to go with it. Both are built by hand rather than through `encode`, because what they need is a payload the schema refuses to WRITE: a `code` that disagrees with the files. A carrier is not held to the schema, so a test of what a carrier can do must not be either. Proved red first. Removing the address check fails the artefact half; writing the bit as a constant fails the payload half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22722replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Read a .docx, and say what a reading view did not draw

The reader does not enumerate element names, because counting them gets the problem wrong: across a real corpus the thirty commonest names cover 92% of the content and ZERO of the documents, since what breaks a reader is the structural singletons that appear once each in every file. So elements fall into four sets, and the fourth is the one that works -- handled, dropped, counted-and-not-drawn, and DESCENDED THROUGH. A content control, a smart tag, a custom XML block and everything invented since this was written contribute nothing themselves and their content is read where they stood. Same move the HTML reader makes, same reason. It reads the document's own vocabulary rather than Word's spelling of it. A paragraph is a heading because its style RESOLVES to a built-in heading name; a list is numbered because numbering.xml says its level's format is not `bullet`, through the two hops numId -> abstractNumId -> lvl. The fixture proves why both matter: LibreOffice gives its `Heading1` style no outline level at all, and calls its quotation style `BlockQuotation`. A reader written against Word alone loses the title of that document entirely, which is what the red run showed. What cannot be drawn is counted BY KIND and handed back -- "4 things are not drawn: 3 text boxes, 1 chart". A picture, a chart, a diagram and a text box are four different absences and calling them all an image tells a reader looking for the chart that there isn't one. A macro project is SAID, never run, and travels with the file untouched. Tracked changes are displayed as the document stands: an insertion's text is in the document so it is read, a deletion's is not so it is not. Nothing here authors either. The fixture is a .docx LibreOffice wrote from HTML whose content we chose: the intent is ours and the bytes are somebody else's, which is the only useful shape for a reader test. A round-trip test sits beside it and is weaker evidence on purpose -- and earned its place anyway, by catching a broken numId indirection the foreign fixture cannot see, because LibreOffice happens to number its abstracts to match. Also `doc::markdown::write`, the tree back out as Markdown, which is what a model wants to read a document as. Its nesting is the width of the marker above it rather than a fixed depth that compounded at every level, and its escaping goes where a character would change meaning and nowhere else -- a price in a table cell is not the start of a line. And the vacuous check in the XML suite now says so in its own words, naming the span-tiling property as the one with teeth, so a future reader cannot take it for proof of what it cannot prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22719replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+A share is a copy the receiver owns, and code travels only by consent

`daimond/share/0`, the third payload schema this container carries. It sends the files of one Diamond to one person, sealed to their key, and what lands is theirs: they may change it, the sender never sees the change, and there is no content key that outlives an edit, nothing to revoke, and nobody's storage in question but the receiver's own. The field that is not obvious is `code`. A shared Diamond carrying a page is carrying a program written by another person, and the receiver decides whether to run it -- so the artefact says, in the part the author signed, whether there is anything to decide. It is required and written even when false, because an omitted false and a sender whose build had never heard of the field are the same bytes, and those are the two things a receiver must be able to tell apart. And it is checked against the files both ways: a page under `code: false` is refused so the bit cannot hide a program, and a claim with no code behind it is refused so nobody is taught that the question does not mean anything. That check is what stops the bit being redundant with the files it describes. The claim is the SENDER's reading of the suffix set, pinned at signing time, so a later build that learns of a suffix this one does not know disagrees with an old artefact instead of quietly deciding for the receiver. Three paths are refused in the format rather than left to a client: `.daimond/`, which is the sender's own agent log and sync stamps; `versions/`, which is their history; and `capp.json`, a delivery record that never happened to the receiver and which, carried across, would pin their copy against updates they never chose. A copy arriving without one is a case the receiving client already knows. Two canonicalisation rules do work here that they do not do for a message. The files are ordered by path and a duplicate is refused, because a set of files written two ways would be one Diamond at two addresses. And a path is refused rather than normalised -- which is where this parts company with the client-side guard it otherwise matches, since that one tidies an untrusted request on the way to a real file and this decides what a signed artefact means. Ten fixtures, and every fixture already committed is byte for byte the file it was: the schema reaches the signing input length-prefixed, so a third name coming to exist re-addressed nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22700replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

106 operations, since the previous mark · compare with the head
+fe2o3_file: create a .docx, and prove it against a foreign reader

A writer from the neutral document tree to WordprocessingML: headings, paragraphs, emphasis, links, lists nested nine deep, quotations, tables with a repeating header row, listings and thematic breaks. Six parts, of which four are what a .docx cannot open without. Everything is NAMED rather than drawn. A heading is Word's own heading style, so its navigation pane and a generated contents page both find it; a bold run at 20 point would look the same and be neither. A list is a numbering reference, not a bullet typed into the text. A link is a relationship the body names by id, and the test checks that every id the body names is one the rels part declares -- the one place a created document can be inconsistent while every part is well formed alone. Checked against LibreOffice, which is somebody else's implementation and therefore the only evidence worth having here: it recovers both heading levels as outline levels, the link's target, the nested bullets, the numbered list, the table's alignments and the listing's indentation. dev/docx_oracle.sh is that check, kept. The alignments settle a question rather than assume one: the document tree names the sides Start and End because it does not know which way its text runs, OOXML has the same two words for the same reason, and LibreOffice reads them as left and right. Nothing here decides what "left" means. An image is the one thing the tree can hold that this cannot carry -- the tree holds where an image IS, and there is no filesystem here. The alt text stands in its place and the omission is COUNTED and handed back to the caller, so a reader is told rather than left to notice. Placement, which is not where it was planned: `office` belongs beside the document tree in fe2o3_text and cannot go there. fe2o3_jdat depends on fe2o3_text, so fe2o3_text can depend on nothing that depends on fe2o3_jdat, and the archive does through fe2o3_data. This crate is on the other side of that line and can use both, so its remit widens to name archives and the document formats built on them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22593replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

24 operations, since the previous mark · compare with the head
+fe2o3_text: XML that remembers where it came from

A namespace-aware reader whose every node retains the byte span it was read from, and an editing model that splices into those spans rather than serialising a tree back out. Nothing is ever written out of the tree: an element with no handler is still a node and renders as its own bytes, so a document nobody edited renders as the bytes it was parsed from, not because that was tested but because no code path could do otherwise. What is tested is the property that makes it true -- the spans TILE the source exactly, every node's bytes in order giving the source back with nothing lost between two nodes and nothing counted twice. A reader that dropped comments, as readers do, fails there and nowhere else. Stricter than the HTML reader next door, because these documents are generator output: a mismatched close tag, an element left open, a bare attribute and an unbound prefix are all refused by name. A tag's end is found by parsing its attributes, since `>` is legal inside a value. This is not `doc`, and `doc`'s own policy says why: a tree that cannot carry markup it has no node for is right for reading and for creating, and wrong for editing a file somebody else wrote. Said in the module docs, because the next person will reach for it. fe2o3_net's UPnP client hand-rolls element_body() and first_element() over strings today, and is the second caller waiting for this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22568replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_file: a ZIP archive that copies what it does not understand

An in-memory archive over bytes, target-neutral, with one property the ordinary archive library does not offer: a member nobody touched is written back byte for byte. Its local header, extra fields, stored DEFLATE stream, data descriptor and directory entry are all the bytes that were read, so the theme, the custom XML and the tracked changes in somebody's .docx survive an edit to one paragraph of it. Reading takes the directory only, so opening a large archive to look at one small part costs the directory. ZIP64 is read and refused on write by name, rather than written back without its records. Encryption, split archives and unknown compression methods are named refusals; an unknown method still copies through. flate2 supplies DEFLATE and the CRC-32, so neither is hand-rolled here, and fe2o3_data comes in with default features off -- rayon does not build for wasm32 and a browser reads these archives. Tested against archives from two foreign writers, Info-ZIP and LibreOffice, both round-tripping byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22554replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Say once what tells a mark somebody named from a mark a tool wrote

Three things recognise the convention -- ore back, the exporter deciding what deserves a tag, and the forge's own listing -- in two crates that cannot see each other's copy. Each could disagree, and a rule implemented twice is the seam this project has paid for before. The predicate goes up and the generator stays down. Nothing but a tool authoring an operation ever produces one of these names, so the generator has no second implementation to drift from, and moving it would put a calendar inside a crate whose stated property is that it holds no clock. The two halves are held together by an assertion across the boundary instead: every name the tool makes must satisfy the rule read from here. It takes a name and not an operation, so a caller holding only something somebody typed can ask. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

45 days agor1870400018:22536replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Refuse a revert that undoes nothing

A note about nothing is a mark with extra spelling, and so is a revert of nothing; the rule is Op::check_note's and this follows it, down to the error tag, because it is the same kind of fault rather than an ordering one. A test that let the empty list be refused for being out of order would send an author looking for a sort bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

45 days agor1870400018:22532replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Let the history say when a mark was made, and what a proposal asks for

Five codes above the eight, so this is the version 3 event again rather than a new kind of one: a version 2 segment still means what it meant, VERSION_MIN does not move, and no store owes a migration. A mark keeps one variant and gains two spellings. Where it carries neither a body nor a time it encodes at code 4 in two elements, byte for byte what was written before; where it carries either it encodes at code 9 in four. The encoding is a function of the content rather than of the author, because a mark that could be spelled two ways is a mark whose signature verifies two different things. A code 9 mark carrying neither is refused on decode for that reason. The time is seconds since the epoch and optional, which is the truth rather than a courtesy: a mark written before the log had a clock has no time, and the mirror's own sidecar remains the honest answer for those. The engine still reads no clock. The caller supplies one. Proposal, Said and Settled put the forge's backlog where every replica holding the repository holds it too, ballots excepted. Reverts says what a set of operations undoes, so a revert reaching somebody else's machine is not an unexplained deletion by a stranger. All six are about the history and not about the bytes: they claim no byte, mint no atom and render nothing, which the sequence already handles by the arm it takes for a mark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aBKK2qkMWh5qa1XxBnvSG

45 days agor1870400018:22513replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

77 operations, since the previous mark · compare with the head
+Reserve the name a share will travel under, and say why it is a name

Sharing a Diamond is coming, and the shape has to be settled before the first real message exists rather than after. The obvious-looking place for it is a fifth arm on post's Target, beside proposal, build, panel and guide. It is the wrong place three times over, and the third is decisive. A share CARRIES what it sends: the thing shared is a copy the receiver comes to own, and a reference is a pointer with a fallback sentence. A share is PRIVATE, and Target's own documentation says private device-local pointers are deliberately absent, because the other party cannot dereference one and an interface must never draw a chip that will always fail. And a share must carry a consent bit the signature covers -- a receiver deciding whether to run somebody else's code can only decide honestly if they can check the SENDER marked it, and a flag a relay could add or strip is not a flag. A Reference carries exactly two keys and refuses a third, so there is nowhere to put it. The first two arguments might be argued around; that one cannot. So SCHEMA_SHARE is reserved as a name, with the reasoning beside it. Nothing constructs or reads one, and an artefact declaring it is refused saying so, which is the right answer from a reader that does not implement it. Reserving it costs nothing already signed. The schema reaches the signing input length-prefixed, so a third name cannot change how a post signed under the first two is read. That is now a test rather than a claim -- and the test says what it does not prove as carefully as what it does. It does not exhibit a collision: in v0 the schema and the hash are not adjacent, and a schema is a String while the scheme id between them begins 0xF5, which is not valid UTF-8. The ambiguity is unreachable by accident of the constants, and the prefix is what makes it unreachable by design. The other property a later fifth arm would depend on is pinned too: an unknown reference kind is refused, by name, and the message carrying it is refused whole rather than arriving with one reference fewer than its author signed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22435replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say the package name the demo generator is actually run by

Left over from the lift: `-p sbj` names no package in this workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22431replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Fixtures for the two schemas that are not node trees

Eleven of them: four a reader must accept, seven it must refuse. Between them they cover each of the four things a message may point at, a body past its ceiling, a nonce of the wrong width, a sealing subkey of the wrong width, a list that is present and empty, a duplicate key that survives only in the bytes, a non-minimal length in the envelope, and the re-labelling the length prefix of §1.3 closes. Meta's node count and depth become optional. A post and a card have neither, and a fixture declaring zero of each would be asserting a measurement of a shape it does not have. The document compiler is excluded from the four record fixtures by an assertion rather than by a skip: it is required to REFUSE to write one. A fixture passed over in silence is a fixture that could stop being run without anybody noticing. The existing forty-six are byte-identical, every file, checked against a copy taken before any of this: no document's address moved and no expected rejection was rewritten to suit the code. README.md alone changed, to say `-p oxedyne_fe2o3_sbj` where it still said `-p sbj`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22429replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

110 operations, since the previous mark · compare with the head
+A payload that is not a tree, and the writer that can put one in a file

The container has carried two record schemas since the post and the card arrived, and nothing in the crate could write or read a file holding either: every route ran through the node-tree validator, which admits the three oxeweb names and refuses the rest. Payload is where a schema name now chooses a validator. A tree is walked, a post and a card are checked by their own field rules, and the enum is exhaustive, so a sixth schema cannot arrive without the compiler naming the places that must learn it. Schema itself is left alone: its job is to fix a vocabulary of node kinds and of style properties, and a record has neither. seal and assemble become public, and envelope_for joins them: the half of sealing that holds no key material, so a signer living where this code cannot reach -- a browser's non-extractable key -- can take a signing input away, sign it, and hand the signature back. An artefact assembled before the signature arrives is refused at step 5 saying exactly that, rather than by a signature library that has never heard of this format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNNMfaiBG6s6KNVUryzBKZ

45 days agor1870400018:22318replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

24 operations, since the previous mark · compare with the head
+Put the config placeholder resolver where both callers can reach it

`{env:VAR}` and `{file:path}` expansion lived inside fe2o3_steel's route configuration, so the Oregami forge reimplemented it. It is generic -- a string, a root directory, and a daticle walk over the two -- and belongs beside the format it is expanding. Two differences between the copies were resolved rather than picked from. An unclosed placeholder now quotes the value as it was written rather than the half-expanded intermediate, because an author should see what they typed. And the wording is the house voice rather than steel's `Config:` prefix, which means nothing in a shared crate. fe2o3_steel still carries its own copy; the delegate that would remove it is not made here, because that file has another session's worktree open on it.

45 days agor1870400018:22293replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say what claiming a file number is for, having read the design

The comments added with the claim described garbage collection as renaming over a live file and orphaning another handle, and offered that as the hazard the claim protects against. Both halves were wrong, and the second was not even the claim's business. Collection rewrites archived files -- the ones sealed when they passed their size limit -- and no writer holds one open. The passage in `bot_initgc` that warns about a rename replacing an inode under a writer's handle is the reason the init-time index rebuild writes *in place* rather than renaming, which is the opposite of what it was read as saying. Ozone separates by ownership throughout: writers are handed their own live pair by the zone survey, collectors work on archived files, and the bots are per zone. What the claim is actually for is narrower and worth keeping. The counter is seeded from the highest number the survey found, which is what makes it correct; before that fix, reissuing a number in use destroyed the sealed file's record state at every rollover. Creating the file is atomic, so a number that is taken cannot be handed out however the counter came by it -- unconstructible rather than avoided. It also makes a zone written by two processes safe, each having seeded from the same disk.

45 days agor1870400018:22289replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Claim a live file rather than counting to it, and drop the lock

An earlier commit added a holder file refusing a store a second process had open. The diagnosis behind it was wrong and this replaces it. Records do not interleave. Data files are opened for append, so the kernel places every record whole at the end whoever is writing. What was actually broken is narrower: the offset written into the index comes from a length cached in the process, and a zone bot handed out live file numbers by incrementing its own counter. Two processes therefore named the same file and each placed records at offsets predicted from its own cache, so the bytes were sound and the index entries named positions inside each other's records. `rollover.rs` already describes the same fault arising within one process, where a reused number costs a whole FileState. A number is now claimed by creating its files with `create_new`, which is atomic, so of two writers racing for one exactly one wins and the loser is told at once. A writer is then the only appender to its own live file, which is what makes the cached length stay true. Numbers are no longer contiguous where a zone is written by more than one process, and nothing depends on that: a zone's files are found by reading the directory. So concurrent writing needs no exclusion, which is the design this database was built to have. What remains genuinely conflicting is garbage collection, the one operation here that is not an append: it renames a rebuilt file over a live one and orphans another handle's inode. It is opt in, so it is settled by deciding which process compacts rather than by refusing to open.

45 days agor1870400018:22285replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Hold an Ozone store for one process, and say who has it

Two processes opening one store both write into the live file of each zone, interleaving records and losing each other's, and neither is told: the appends go through separate handles at separate offsets and each keeps its own index. Nothing in the database could make that safe, since the two share no channel to co-ordinate over. What it can do is refuse the second one at the moment it opens rather than at the moment it corrupts. A `holder` file in the store root carries the holding process's identifier, created with `create_new` so two processes racing a fresh store cannot both win. It is claimed before the configuration file is written, which is the first write, so a process turned away has changed nothing. An existing claim is checked rather than obeyed. A process that is killed leaves its holder behind, and a store nobody can reopen without deleting a file by hand would be worse than no guard: a holder that is gone is taken over, and only a live one refuses. The refusal separates the two readings, because they want opposite actions and the reader may be somewhere unhelpful at the time. Either the named process is the holder, and stopping it is the answer; or an identifier was reused after a hard kill, and removing the file is. It says which file, and says where to look to tell them apart. Found because a downstream application had begun inventing its own guard. The gap was the library's, so the fix is.

45 days agor1870400018:22274replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Check a whole history's signatures at once

Verification is 45% of a forge page on the fe2o3 import -- 390 ms of 860, at 38 microseconds a signature over 10,183 operations -- and a replay verifies every operation it reads. Two changes, neither of which alters what the system accepts. `Signer` gains `verify_batch`, defaulting to a loop so every implementor is correct unchanged, and `SignatureScheme` overrides it for Ed25519. Dilithium takes the default. The doc comment carries the contract that matters: a batch says the set does not hold and cannot say which member failed, so a caller owing the reader the name of the failing operation must fall back to verifying singly. `Store::replay` and `check_incoming` do exactly that, and refuse anyway if the fallback finds nothing rather than accepting a set the batch rejected. The decompressed verifying key is cached per signer within a batch, which is the larger win here and does not depend on the batch equation at all: a history signed by one key decompressed it once per operation. Dalek's batch equation decompresses R where its single check compares bytes, so a non-canonically encoded signature would pass the batch and fail singly. That is malleability rather than forgery, but it is a widening, so `is_canonical_point` rejects the encodings that differ and the two predicates accept the same set. The wasm32 guarantee holds: 2.x derives its challenges from a merlin transcript over the inputs, with no OS randomness on the path. Checked for both the engine and the scheme with the feature on.

45 days agor1870400018:22266replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+An identity card, and the two renderings a person actually compares.

`daimond/card/0` is the third schema and the second that is not a tree. A card is what a QR code carries and what a paste carries; a bare public key is not, because it cannot say which key signs and which seals, carries no label, and gives a reader no way to tell a first key from one that replaced another. Most of a card is the envelope's already, which is the argument for putting it here: the signing key is `author`, the signature is `sig`, the algorithm is `sig_scheme`, the creation time is `time`. What is left is what a key cannot say about itself -- `label`, `enc`, `role`, `prev` -- and that is all this schema defines. Self-signed is stated plainly in the module doc because it is the thing most easily over-read: a card verifies under the key it carries, so it proves the holder of that key composed it, and proves nothing whatever about who that holder is. A card fetched from a server verifies perfectly and is still Unverified, because an intermediary that substituted its own key would produce one that verifies just as well. `prev` is signed by the NEW key only, so it is a claim of succession and never a proof of one -- otherwise anybody could claim to supersede anybody. `fingerprint` and `safety_number` are here so that there is ONE implementation of each. The plan records that `Account` already serves a fingerprint and the design specifies another, and that these must be the same function or one must go; two renderings that eventually disagree on some untested key would show a user their correspondent's key appearing to change. The fingerprint decides nothing and says so twice: equality is always the full 32 bytes, and eighty bits is well within reach of somebody who wants two keys to look alike in a list. The safety number is the whole digest and never a prefix, because the attack is a meet-in-the-middle at 2^(n/2) and halving the width quarters the exponent. Crockford base 32 goes in `fe2o3_text::base2x` rather than here, because `Base2x<32, 5>` already exists and Crockford is an alphabet rather than a codec. Reaching for the generic thing that was already built is the whole point of the preference order. Twelve tests. The fingerprint's alphabet is restated in the test rather than read from the constant, so the check is not the code agreeing with itself.

45 days agor1870400018:22247replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+A message is the second schema, and the container did not have to change.

`daimond/post/0` is the first payload here that is not a node tree. A document is a tree because a document is one; a message is a record of five fields, so the payload is a single canonical map and §4 does not apply to it. The envelope, canon, the limits and the verification order are untouched, which is the claim SBJ has been making about itself since it was written down. What the payload carries: `body`, `to`, `nonce`, and optionally `reply_to` and `refs`. What it deliberately does not carry is as load-bearing. There is no `from` -- the author is the envelope's, so there is no second place to say who wrote it and no spoofable name to disagree with the key. There is no `created` -- a timestamp is advisory and belongs to the envelope, and a payload asserting its own clock would be asserting one nobody can check. A reference carries no sender-supplied title, only the referent and a fallback the reader draws on failure: a title is stale the moment a proposal is renamed, and it is arbitrary sender text drawn as though it were a forge record. `body` is a BU32 and a BU8 is refused by name, since a BU8 truncates silently past 255 bytes -- a defect that surfaces the first time somebody writes a long message. It is carried as bytes, so `canon`'s string rules do not reach it and this schema applies them itself: without that, one text could have two encodings and so two addresses, which is the whole of §3 rule 5. References are an enum over four PUBLIC anchors -- proposal, build, panel, guide. A private, device-local pointer is refused by name and says why: the other party cannot resolve one, and an interface must never draw a chip that always fails. The target is a map with exactly one entry, the same rule and the same reason as a link address in §4.3. Twenty-five tests, and the negative ones were proved red rather than assumed: removing the empty-list guard reddens exactly `test_empty_refs_list_refused`, and removing the text check reddens exactly the three body-text tests, with the other twenty-one staying green. A rejection test that passes for any error is a test that cannot tell "rejected" from "rejected for the right reason".

45 days agor1870400018:22241replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+SBJ comes into the workspace, where the crates it is built from live.

`sbj` was written in the oxegen tree against seven fe2o3 crates by path, which made it a downstream application of a library it is really a part of: a signed binary envelope over BDAT is serialisation, and serialisation is this workspace's business. It sits above `crypto` and `hash` and below `steel`, and every one of its dependencies -- core, text, jdat, crypto, hash, iop_crypto, iop_hash -- is already beneath it, so the layering needed nothing invented for it. Lifted whole rather than reimplemented, which is the point: `canon.rs` already enforces every byte-level rule by re-encoding what it decoded and demanding the same bytes back, SPEC.md is normative, and the 46 conformance fixtures are the format's teeth. A second encoder written here would have been a second answer to a question that already has one. The four oxegen crates that used it -- kiln, oxeye-core, oxeye, sbj_wasm -- now depend on `oxedyne_fe2o3_sbj` across the tree boundary, and `oxegen/sbj` is deleted rather than left as a copy to drift. Tests move with it and pass here: 159 lib, 4 CLI, the conformance suite over all 46 fixtures, 3 doc tests.

46 days agor1870400018:22236replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

301 operations, since the previous mark · compare with the head
+Rebuilding an index must not replace the file the writer holds open

A zone whose index file was empty recovered by rebuilding it -- and the rebuild renamed a fresh file over the index, REPLACING THE INODE underneath the write bot's already-open append handle. The bot went on writing, into a file nothing would ever open again. Every index record for the rest of the process was lost. The data file is never renamed, so get() stayed perfect and only scan went blind: a zone that held records answered as though it held none. Self-perpetuating, since every restart rebuilt, detached, and lost that run's writes again. Seen in a store from 2026-08-14: zone_001's index 0 bytes, its data file 5805, holding records that had been successfully read by key during the same run. The mtimes say it exactly -- the index last touched at the rename, the data file sixteen seconds later by writes that never reached it. The rebuild was added on 12 August to repair this state; it was causing it. The rebuilt index is now written into the existing inode, so the open handle stays valid and the bot's writes land after the rebuilt records. Atomicity is traded away on purpose: renaming GUARANTEES loss, while in place risks a partial index only if the process dies inside one write of an in-memory buffer -- and a partial index is caught by the size check on the next start and rebuilt. Collection keeps its rename, which is correct there: no writer holds a collected file open, and concurrent scanners do. A scan can now report a shortfall rather than answering with silence. The check is exact rather than heuristic -- each index record carries klen + vlen, so one index file's records sum to its data file's length. Each data file is measured on BOTH sides of its own walk and the smaller used: a collection only shrinks and an append only grows, so the smaller is under the coverage in both races. Measuring up front instead would have false-flagged nearly every walk under a collection backlog. Proved red: restoring the rename fails the new integration test, which asserts that after recovery a single write makes the index file ON DISK grow -- an assertion that never touches the scan, so fixing the scan cannot satisfy it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNerD7Ht6YcrWgPPcZnykm

47 days agor1870400018:21934replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

29 operations, since the previous mark · compare with the head
+Only the hop may say where a request came from, and get_last is how you read it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VNerD7Ht6YcrWgPPcZnykm A caller's own X-Forwarded-For was copied through and this hop's value appended after it, so a request arrived carrying two. HeaderFields::get_one returns the FIRST, which is the caller's -- so anything keyed on it grants a fresh allowance per invented address, which is no limit at all. A downstream forge found this the hard way and reads the last value to defend itself. Now: a caller's copies of x-forwarded-for, -proto, -host and forwarded are dropped unless the peer is a configured trusted proxy, and this hop appends its own. THIS HOP'S VALUE IS LAST UNDER EITHER POLICY -- one value when the peer is untrusted, the caller's chain preserved and ours appended when it is trusted -- so a reader taking the last value is correct by construction rather than by arithmetic over a list an attacker contributed to. Asserted in both branches, by putting the built head back through the wire parser rather than scanning a string, so what is asserted is what a reader receives. Stripping unconditionally would be wrong the day anything sits behind a CDN: the real client address would be discarded and every visitor replaced by the CDN's egress. Hence trusted_proxies, parsed at start-up so a typo is a start-up failure rather than a silently empty allow-list. HeaderFields::get_last is new and is half the fix. Its absence is why get_one was reached for. Its doc says when each is right -- get_one for a field whose sender is its authority, get_last for every field a proxy chain appends to -- and records that a value here is one LINE, so 'a, b' is one value and not two. Trusted-proxy policy and hop-header hygiene are generic HTTP, so they live in fe2o3_net::http::fwd; fe2o3_steel keeps only its trusted_proxies config. The tests that drive Steel end to end stayed with Steel, because they answer the one question the library tests cannot: whether Steel calls the lifted code at all. 40 tests. Proved red: a policy that trusts every peer reddens three, the either-policy invariant among them.

47 days agor1870400018:21904replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

40 operations, since the previous mark · compare with the head
+Carry HEVC across too, and stop calling every film AVC

Three quarters of the films this serves are HEVC, so a repackager that handles only H.264 leaves most of the collection where it was. Both codings are carried in length-prefixed NAL units and both state their configuration in a record the source container already holds, so HEVC is a `Codec` variant and a sample entry of `hvc1` carrying the `hvcC` verbatim. Nothing is decoded and nothing is re-encoded, exactly as for AVC. The geometry comes out of the record rather than being trusted from the container: `hevc::config` gives the parameter sets and `hevc::sps` reads the coded size out of the sequence parameter set. Those already existed, so this adds no parser. The parameter sets come out of `hevc::config` already unescaped -- `Unit::body` is the RBSP and `Unit::raw` keeps the escaped form beside it -- so nothing is unescaped a second time, which would have produced a plausible wrong size rather than a failure. Two things were wrong before and are fixed here, neither of them about HEVC as such. A film's compressor name was the literal `AVC Coding`, written into every visual sample entry whatever the film was coded in; it is not a match, so it would have gone on saying AVC about HEVC for ever without a word. And the whole-file `ftyp` listed `avc1` among its compatible brands unconditionally, which on an HEVC film is a claim of conformance to a specification it does not conform to. `ftyp` now takes the codec: `avc1`, `hvc1`, or neither for a film that is only sound. ffmpeg agrees the distinction matters -- it drops `avc1` from a whole-file HEVC film and includes it for H.264. The fragmented `ftyp` is deliberately left alone at `iso5 iso6 mp41`. That list was measured from ffmpeg's own fragmented HEVC output, which adds no fourth brand; and a fragmented film may carry several streams, so a whole-file conformance claim taken from one of them would be wrong in general. Held to ffmpeg over real films: three HEVC features repackaged out of Matroska at 1920 by 1016, 800 and 816, every frame decoding and every picture shown where the source put it. The AVC and fragmented paths are unchanged and still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bJSLj2Gez35A1DCpi98um

48 days agor1870400018:21863replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

43 operations, since the previous mark · compare with the head
+Write a film in fragments, so a long one need not be held whole

`Track::finish` returns the whole file as one `Vec<u8>`, which is right for a poster and impossible for a film: a four-gigabyte repackaging would be built in memory before a byte of it could be served. And it writes one track, so a film made with it has no sound. Both wanted the same restructure, so both are here. A fragmented film states its timing per fragment rather than in one sample table, which means there is no `stco` to patch, no `stsc`, and no sample tables at all -- the `moov` carries empty ones and each `moof` describes its own run. Fragmenting is therefore SIMPLER than indexing several tracks, not harder, which is why this is a second writer beside `Track` rather than a change to it. `Track` is untouched; posters still want it. `Stream` carries a start time, and it is not decoration. A film's first sound frame is rarely on the same instant as its first picture, and a picture track shifted so that none of its composition offsets is negative has moved relative to sound that was not shifted with it. Without somewhere to say that, the two are nailed to a common zero and the film carries an offset between picture and sound that no caller can remove. The `trun` is version 1, so composition offsets are signed. ffmpeg writes version 0 and cannot express a negative one, so it compensates elsewhere: it inflates the first sound sample's duration from 1024 to 4864 -- exactly the video's 80 ms composition delay -- because with an empty `moov` it cannot know the delay in time to write an edit list. That is a workaround for a missing `elst`, not a thing to copy. The format was measured rather than recalled. A reference file was made with ffmpeg and taken apart box by box, and two of its choices are deliberately not followed: it writes `next_track_ID = 2` for two tracks, where the field must exceed every track id in use, and it sets `tfhd` defaults that this writer omits in favour of stating every value per sample, which is always correct and needs no defaulting. Held to ffmpeg over real films: both streams written across several fragments, every frame decoding without complaint, and each track's times read back and compared to what was handed to the writer. The fifth question is the one that earns the test: checks on each track separately each allow their own track one constant delay, so together they permit the two to be delayed by DIFFERENT amounts -- a film whose sound is out, which every earlier check passes happily. All three breaks were proved to reach their own check and no earlier one: a single picture offset moved one tick fires the picture comparison; one sound offset moved one tick fires the sound comparison; and a whole sound track delayed two hundred ticks -- internally perfect, one constant from its source, exactly what the sound check permits -- fires only the comparison of the two tracks against each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bJSLj2Gez35A1DCpi98um

48 days agor1870400018:21819replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+State when a picture is shown as well as when it is decoded

A film repackaged out of Matroska would not play, and the reason was that the writer could describe only a stream whose pictures are shown in the order they are decoded. Where B-pictures are used they are not: a picture is decoded before the ones it is shown between, so a container has to state both orders. `stts` gave the decoding times and nothing gave the difference. Nearly no film is such a stream. The one measured here has 41 B-pictures in its first 60, shown at 0, 160, 80, 40, 120 while decoded at 0, 40, 80, 120, 160. So `Sample` carries an offset, `ctts` is written when any of them is not nought, and `composition_offsets` works them out from the presentation times a source container states -- which is the form a film arrives in, because Matroska states only when a picture is shown and leaves the decoding order implicit in the order the frames come out. The offsets are all raised by one constant so that none is negative. A picture at the head of a reordered run is decoded early precisely so that the ones it is shown between can refer to it, so its raw difference is negative, and a negative offset says a picture is shown before it is decoded. Raising them all delays the whole film by a few frames and leaves every interval between pictures exactly as it was. An absent `ctts` is written for a stream that never reorders rather than a table of zeroes, because absence is what states that the two orders agree, and the table would cost four bytes a sample to say it again. Held to ffmpeg over real films, in three questions rather than one, because the first two both pass on a film that is wrong: ffprobe agrees the written film is h264 of the right size with the right packet count; ffmpeg decodes every frame without complaint; and **the presentation times are read back and held against the source's**, which may differ by one constant and not by two. Five films, 400 frames each, all three green. The third question is the one that earns its place, and it was proved so rather than assumed. Zeroing the offsets fails at the decode. Rotating them fails at the decode. But moving a single offset by one tick -- which is what an off-by-one in a run-length table looks like -- decodes cleanly, counts correctly, and is caught only by comparing the times: frame 17 shown at 721 where the source says 640. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bJSLj2Gez35A1DCpi98um

48 days agor1870400018:21812replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

26 operations, since the previous mark · compare with the head
+Ask whether a repackaged film plays, and find that it cannot yet

The end-to-end claim the repackaging rests on is that a browser refuses an MKV for its container and not for its contents, so moving the same coded bytes into MP4 makes the film play. This asks that question of a real film and a second program, and the answer today is no. **The test is committed red, on this branch only, because it is red for the right reason and it is the check that will say when the gap is closed.** What it found: the MP4 writer cannot express B-frame reordering. Frames leave a Matroska cluster in decode order carrying presentation times, and MP4 states the two separately -- `stts` for decode durations and `ctts` for the difference. The writer has `stts` and `stss` and no `ctts` at all, so it can only describe a film whose pictures are shown in the order they are decoded. Nearly no real film is such a film. The one measured here is 41 B-frames in its first 60, and its packets carry presentation times of 0, 160, 80, 40, 120 against decode times of 0, 40, 80, 120 -- so writing the presentation times as though they were decode times produces exactly what ffmpeg reports: a decode time that goes backwards. Worth stating plainly against the earlier estimate, which held that repackaging needed no encoder work: that is still true, and it was not the whole cost. Composition offsets are muxer work, but they are more than the fragmented output already known about, and the caller must derive decode times itself -- Matroska stores only presentation times, so the reordering delay has to be recovered from the frames rather than read. The container half is checked before the decode half deliberately. ffprobe agrees the written film is h264 of the right size with the right number of packets, and it is wrong regardless: a container written badly still opens and still counts plausibly, and only asking a decoder to run through it says whether the bytes it was handed were the frames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bJSLj2Gez35A1DCpi98um

48 days agor1870400018:21785replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Lift the frames out of a film's clusters, and time a laced one properly

Reading the header says what a film is; repackaging it into a container a browser will play needs the frames themselves. This is the other half of the Matroska reader: the clusters, the blocks and the frames inside them, decoding nothing. What comes out is the codec's own bytes, unaltered, which is what makes a repackaging lossless and what makes it possible without a decoder at all. It is fed rather than handed the file. A film is gigabytes and the whole point of repackaging is to avoid the memory a decode would cost, so `Segment` and `Cluster` are descended into by consuming their headers alone and their children read as though they sat at the top. The window a caller holds is therefore one block -- a frame -- and never one cluster, which is megabytes, and never the film. Elements that are not wanted are passed over by their stated length without entering the window at all, so a film carrying cover art costs nothing to skip. The frames of a laced block are spaced by the block's own span divided among them, not by the frame duration multiplied up. The two differ because a timestamp unit cannot represent a frame of sound: AAC at 48 kHz lasts 21 1/3 milliseconds and eight of them are 170 2/3. Multiplying accumulates the third of a millisecond until the last frame of a block is stamped later than dividing says, and in the limit past the start of the block after it. Dividing keeps every frame inside the block that carries it. That fault was found by the oracle rather than by reasoning, and only because the comparison covers the sound. Lacing is an audio phenomenon -- a film's picture is one frame a block and exercises none of it -- so the first version of this test compared the picture alone, passed, and left the lacing arithmetic wholly unproven. Comparing `a:0` as well failed on the second frame of the first film: the sizes were right, every frame was there, and only the clock was wrong, which in a repackaged film is sound that walks away from the picture. Held against `ffprobe -show_packets` over real films, comparing each frame's size, presentation time and keyframe flag in order: 15,000 frames across five films, both streams of each, no disagreement. Proven red first by three deliberate breaks -- ignoring the cluster timestamp, calling every frame a keyframe, and reading the block header a byte early -- which the comparison caught at frames 76, 1 and 0 respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bJSLj2Gez35A1DCpi98um

48 days agor1870400018:21782replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Read what a Matroska file says about its streams

A film collection is written mostly in Matroska, and a catalogue that cannot open one under-reports what is on the disk. This reads the header and decodes nothing, as the AVI reader beside it does. It reports every track and not only the picture. The question a library asks of this reader is whether a browser will play the file, and that is settled by the sound as often as by the picture: AC-3 is common and no browser decodes it, so a reader that found the picture and stopped would call such a film playable. Held against ffprobe over 1,334 real films: 1,333 agreed on the size, the running time and every stream's codec, worst running-time difference 0 ms, none needing more than 64 KB of head. The odd one out is a dangling symbolic link. Thirteen more files the reader refuses are empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1bWBDWXyvFfT4vuP79dag

48 days agor1870400018:21772replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Give the map readers an unsigned sibling

`Dat::map_get_i64` and `map_get_f64` had no `u64` counterpart, though `Dat::get_u64` has existed all along. A caller reading a `Dat::U64` byte count through `map_get_i64` reinterprets the sign bit rather than erroring, which is the wrong answer rather than an answer refused.

49 days agor1870400018:21766replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Garbage collection was writing index files nothing could read

An index record is chash || key || cind || meta || csum || floc. StoredKey::load strips the four-byte cache hash off the front of what it returns; cache_data_file wrote those stripped bytes straight back. So after every collection, every record in the rebuilt index was four bytes short and the file was undecodable from its first byte. Nothing noticed for as long as this has existed, because the only reader was initialisation -- which catches the failure, warns, and quietly falls back to scanning the data file. Then scan arrived, with no fallback, and died on "Dat identification code 101 not recognised". That is the UpstreamErr the operator console has been showing for every view that scans, while single-key reads carried on working because they never open an index file. inc_index_file_size was under-counting by the same four bytes per record, so zone directory accounting drifted low after each collection. Both come from one line. The rebuild is now atomic. It used to delete the index and rewrite it in place, so for the length of a rebuild the file was absent and then partial -- already a hazard at the shipped default of two collectors per zone, where one could walk a file the other was mid-rebuild on and drop every key whose value lived there. It writes the .gc temporary and renames over the index in one step, so a reader sees the whole old file or the whole new one, and a crash leaves the old one intact. And scans no longer queue behind collection: they go to their own per-zone pool (num_scbots_per_zone, optional, default 1) rather than to the collector, whose loop runs collect_garbage and cache_file inline to completion. The reader bots were the cheaper home and were rejected -- a whole-zone walk ahead of a get makes every single-key read wait, and get currently works. The test builds the backlog with collection off, then switches it on and dispatches the lot before scanning. It fails on unmodified code every run. Its latency assertion is a tripwire against scans returning to a shared queue, not a proof: a collection touches one file and a scan touches all of them, so the coupling is bounded at about two whatever the store size, and the six-second timeout cannot be manufactured at any size a test can afford. The correctness half is the sharp half.

50 days agor1870400018:21764replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

80 operations, since the previous mark · compare with the head
+Shield states its deviations from the Ozone defaults, as Steel already does

An exhaustive config literal is a promise to update it whenever the defaults move, and nobody keeps that promise. Every value it currently uses is restated, so behaviour is unchanged. The 1500-byte chunking threshold and 64-byte chunk size are left alone with a note: Steel's own comment records them as inherited from the TEST setup, and changing them alters how values already in a Shield store were split.

50 days agor1870400018:21683replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Let Steel come home, instead of being killed with its stores open

The accept loop had no way out. `Server::start` ended in a `loop` around `accept` with no break and nothing selected against it, so the "Server stopped gracefully" a few lines below it was unreachable in production and every deployed Steel was felled by `SIGKILL` with its Ozone instances still open. There was no `ExecStop` anywhere in the workspace either, so nothing asked it politely first. That is not a tidiness matter. A store killed mid-write leaves records short of their checksum, and a downstream library has spent this week recovering from exactly that. The loop now selects the stop against the accept -- both arms are cancel-safe, which is what makes the select legitimate -- returns the port before draining, and gives whatever is in flight `DRAIN_SECS` to finish. Five seconds, and a connection is counted while it is *open* rather than only while it is being answered, so a keep-alive and a WebSocket both hold the count up and a busy server usually waits the whole five. That is the price of not cutting off the one response that was mid-body, and it is well inside systemd's default ninety. Then every vhost's store is closed. One Ozone per vhost, not one per process, so the whole map is walked; a poisoned lock is recovered from rather than propagated, and a store that will not close does not stop the others. **A second fault only the first fix could reveal.** With the loop mended, the stop came all the way home, closed the database, said so, and then hung for ever one line from the end: in dev mode the file watcher is spawned onto the blocking pool and never returns, and dropping a runtime waits on that pool. `shutdown_timeout` rather than a drop. The listener goes in `app/tui.rs::run_with_extension` and not in `main.rs`, because `main.rs` is only the stock binary -- every app built on Steel as a library calls `run_with_extension` directly, and would have been left deaf to a reboot. It is installed after the logging is configured, so what it says on the way out is written down. A stop arriving when nothing is serving -- a one-shot command, the interactive shell -- flushes the log and leaves. Catching a signal and merely noting it would have made Steel start ignoring a `SIGTERM` it used to obey. The two process-level tests send a real `SIGINT` and a real `SIGTERM` to a real server and require an exit status rather than a signal, the port released, both shutdown lines, and a genuine reopen with a round trip -- a reopen alone proves little, since Ozone acknowledges every write and a killed store still reads. They wait for "database(s) open and attached" before signalling, so a sealed start cannot pass them while proving nothing. Both fail on the build before this with "it was felled by signal 15, which means it caught nothing". The drain was proved by breaking it: 30 MB at 10 MB/s, stopped one second in, gives a truncated body and a curl error at nought seconds and an identical file at five. The first attempt used 5 MB and passed at nought -- the whole body had gone into the socket buffer, so the check proved nothing. A transfer has to outrun the kernel buffers to have teeth.

50 days agor1870400018:21674replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Hear the operating system ask a program to stop

A long-running program is not asked to stop in words. It is sent a signal: `SIGINT` when somebody presses Ctrl-C, `SIGTERM` when a service manager or a reboot says to go. Nothing in this library heard either, so every program built on it was killed where it stood, and anything holding a store open was killed in the middle of a write. `fe2o3_core::stop::on_stop_request` takes a closure and calls it on every ask -- `SIGINT` and `SIGTERM` on unix, and on Windows the three console events. One listener to a process, because a signal arrives at a process rather than at an object, and a second call is refused rather than quietly stacking a second thread behind the first. **There is no `unsafe` in it, and that is the point.** Three of the four routes from a signal to a program need one -- `sigaction` and `SetConsoleCtrlHandler` are `extern "C"`, and `signal_hook_registry::register` is an `unsafe fn` because whatever it registers runs in signal context. The fourth is tokio's `signal` module, whose whole public surface is safe: the registration and the signal-context work sit inside tokio, and what reaches the caller is an ordinary stream read on an ordinary thread. So this crate keeps `#![forbid(unsafe_code)]` and so does everything downstream of it. It also means the closure is under none of the restrictions a signal handler is under: it may allocate, lock, log and take as long as it likes. The dependency is declared for every target but `wasm32`, mirroring the wasm block already in the file: there are no signals in a browser, and the feature pulls in `mio`, `libc` and `signal-hook-registry`. Checked rather than assumed -- `cargo tree` shows those three on native and tokio alone on wasm32, the wasm build passes, and the Windows arm compiles under `x86_64-pc-windows-gnu` rather than merely looking plausible. Nothing here can be proven from inside a test binary: a test cannot send itself a signal and go on being a test. The claim that a real signal reaches a real program belongs to whoever has a program to stop, and the first caller proves it with four process-level tests.

50 days agor1870400018:21651replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Make the author dump save, not merely draw

The take-over button works by handing the save to the composer's autosave, and the dump carried only the author row -- so it proved the control drew and nothing about whether pressing it wrote anything. It now renders the fields that autosave needs and both scripts, which makes the handover between them something a browser can be pointed at. Driving it: click, one POST to the save endpoint, `author=` empty, which is what the handler reads as "attribute this to whoever is signed in". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21646replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give the author line its own row, so its control is legible

The take-over control shipped into the field row, where it inherited `flex:1 1 8rem` alongside five fields and was crushed into a column two words wide: the label wrapped onto two lines and "Write as me" wrapped onto three inside it. The user could not find a button that was on the screen, which is a fair description of it not being there. It is a note about the post rather than a field of it, so it takes a line of its own at the foot of the row, centred, with the label and the button held to one line each. The render that missed this drew the row in isolation. The dump now builds the same field row the composer builds, so what is photographed is the geometry a person actually meets -- which is the whole point of photographing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21643replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Keep a card's mark where the post keeps it

It went to the card's foot because at 48px it appeared to tower over the head's line of small meta text. The size was never the fault: blog.css sets a card's meta line to `align-items: baseline`, at a specificity the site's own mark rules could not beat, so the mark sat ON the text baseline and stuck up above the line. The full post was never affected -- its line is a different element, already centred. So the mark goes back beside the reading time, and the two surfaces show the same facts in the same order. A reader moving from the list to the piece is not asked to look somewhere new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21639replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Let a post be taken over, and move a card's mark off its head

**A post could not be re-attributed.** It carries the username of whoever saved it, the editor posts that back in a hidden field, and nothing in the console could change it -- so a post written under an earlier identity (an import, a member account since retired, a renamed operator entry) had a byline reading Anonymous for ever, pointing at a name that has no profile and no way to acquire one. The editor now offers "Write as me" where the author is somebody else, and offers nothing where it is already yours. Clearing the field is the whole mechanism: the save handler attributes a post naming no author to whoever is signed in, so the control empties the input and lets the ordinary autosave run. The event is dispatched by hand because setting a value in script fires none -- the same reason the chip scripts dispatch one. **A card's mark moves to its foot, right of Read more.** At the size a level has to be drawn to be countable it towered over the card's line of small meta text, which is where it was. The foot already carries something the size of a control. A post's own mark stays with its reading time, where the line has the room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21628replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+Put a post's declaration beside its reading time, and give a post back the one it has

Three faults, one of them losing data. **The composer never got the level back.** `post.json` did not carry `ai_level`, so the app's editor opened every post reading "Not declared" and the next autosave wrote that back -- taking a declaration its author had made. The field a form writes must be a field the form is given. **The mark sat in the wrong place.** It had a row of its own above the prose, which made it a heading rather than a fact about the piece. It now sits in the meta line, immediately right of the reading time, where a reader is already being told what the piece is before deciding to read it. No words there: they would repeat on every card in the list, and the mark is drawn large enough to be read without them. **The site's own declaration was on every page of the blog.** It says something about the whole site, so it belongs in the site's own footer and is said once; under every post it was the same sentence on every page a reader opened. The configured declaration is still served -- the front page draws it from `declare.json` -- it is simply not drawn here. One size, `MARK_SIZE_PX` = 48, wherever this module draws a mark. The scheme's floor of 40 is where a level stops being countable at all, which is a bad place to sit: it leaves nothing for a cheap screen, a low zoom, or a reader not looking closely. `Size` therefore carries its size in both arms -- words are for a footer with room to spell a claim out, not a consolation for a mark too small to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21611replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

30 operations, since the previous mark · compare with the head
+Take the Save buttons off the declarations, and stop repeating the answer

A row here is one field with one answer, and its whole state is what the select says. A button beside each row was therefore a column of buttons all doing the same single thing -- which is the reasoning that took the Save button off the composer in July, and the reason the app's own version of this screen never had one. The two surfaces now agree. Saving on the change also removes the arrangement the render showed: a button in its own actions block under every select, so the rows sat at uneven distances from each other depending on how the label wrapped. Nothing is said on success. The box already shows the answer, so a sentence repeating it states the same fact twice -- and a single status line under a column of rows reads as belonging to the last row rather than to whichever one was just changed, which is exactly how it read. A failure still speaks: that is the one thing the select cannot show, since it goes on displaying a value the server did not take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21580replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Close a database that more than one handle is holding

`O3db::shutdown` consumed the handle, which cannot be reached through an `Arc` that a server and its worker threads share, and ended on a wait group that had been cloned into every handle -- so a shutdown waited for its own siblings and never returned. Both failures were silent hangs. `O3db::close(&self)` closes the store through any handle. The wait group and the record of what has been done now sit once behind a shared lock, so the wait answers the question it was asked and a second caller finds the work already finished rather than sending a request to a supervisor that has gone. Closing twice is safe and says so. `shutdown` remains, as a wrapper, so no caller changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01249XLp5okAu9g2qrMEBtsV

50 days agor1870400018:21572replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Hand a client the ladder along with the declarations

A chooser needs the five rungs in words, and the composer, the console panel and the app's own screen were each about to keep their own copy. So declare.json carries the vocabulary beside the declarations and every surface renders from that -- one list, and no second one to drift. The declarations page now wears the same clothes as the settings pages beside it: a rendered console showed labels and selects landing in two different arrangements depending on how long the name was, because the form was not built the way the others are. Also adds a render dump for the reader's own screens, the counterpart of the console's. Both of this module's invisible defects were found by rendering it and neither by reading it, and until now the half a reader actually sees had no way to be photographed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21554replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Let a site say how much of a thing was made with AI

A declaration is a claim a person makes about their own work, so the module's whole shape follows from one rule: it must never draw a claim nobody made. An unknown rung, an unreadable record, an absent field and an author who has not chosen all read as no declaration, and no declaration draws nothing -- never the bottom rung, which is itself a claim and the one with the most to gain from being asserted quietly. A post carries its level in its own record, beside the prose it is about, and chooses it in the composer. Everything else a site shows is authored somewhere else -- a book in a catalogue, a project on a front page -- so the config names what may be declared for and a new Declarations page in the console is where an admin says what each one is. The store holds one word per item, and taking a declaration back deletes the record rather than storing a word for saying nothing. No scheme is named in the engine. The ladder is public and its words are ordinary English, but the site that defines them and the artwork that draws them are configuration, so a site declares under whatever scheme it declares under and one that configures none draws nothing anywhere. Two details worth keeping. The size rule is structural: a level is read by counting pins, the count fails as the mark shrinks, and a mark too small does not look broken -- so Size::alone will not return a wordless mark below 40px, and there is no way to spell the illegible arrangement. And the artwork is used as a mask over currentColor rather than as a picture, so one set of files reads correctly on a dark site and a light one without a per-site copy to keep in step. Also fixes a test left red by 7ee7931, which deliberately made the masthead say the site's name rather than what it calls its posts. The behaviour is right and is live; only the assertion had not caught up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN4DJ7n77QyC8VH2B6CwUN

50 days agor1870400018:21538replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

55 operations, since the previous mark · compare with the head
+A text is read in the dark, and mail is not the only way to reach somebody

Three things, all from watching a real operator use it. The text message said "jarrah IS DOWN -- not answering for 3m, seen from karri." It now says "jarrah is DOWN". How long, which machine noticed and what was probed are in the email, which costs nothing to make longer; on a lock screen at three in the morning they are noise in front of the one word that decides whether you get up. The heartbeat is on a timer at last. The event, its wording and its routing all existed and nothing raised it -- so the path could rot silently, which is the one thing it was built to prevent. It rides the watch loop rather than a timer of its own, so a heartbeat arriving is evidence the watcher is running and not merely that something somewhere still fires. Monthly by default. And alerting no longer insists on an email recipient. jarrah cannot send mail at all -- its provider blocks outbound 25 -- so requiring a mailbox would have left the machine that most needs to speak unable to say anything. Mail or text, one of them is enough; neither can prevent the other; with no mail recipient it says so at start and sends only texts. Proved from both ends. karri texted its heartbeat, jarrah texted a deliberately failing peer, and the phone was silenced with Do Not Disturb on for the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21482replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+An alert names the machine, and stops guessing at what somebody else's does

Two things wrong with the sentence an operator actually reads. It called the machine by the first website in its config. karri serves four vhosts, and need2know.ai sorts first, so "jarrah is down, seen from need2know.ai" named a site with nothing to do with either end of it. The kernel knows the answer; /proc/sys/kernel/hostname is read the way fe2o3_sys reads everything else about a host, with the first vhost kept as a fallback for a system that carries no hostname. And the down message ended "Payments, credits, sync and mail on it are unavailable" -- Daimond's services, asserted by a library that has no business knowing them, and wrong for the second peer anybody adds. What a peer DOES is its name, which the operator chose and which travels with every message about it. The sentence now says only that it is not answering, and for how long. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21462replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+One reader for a list of strings, because two of them disagreed about vek

A jdat list arrives as Dat::List, or as Dat::Vek when it was written with the type tag. They mean the same thing to a reader, and a configuration file uses whichever its author typed. The alerts.to parser knew both; the alerts.sms.to parser I wrote beside it knew only one. So a number written in exactly the same form as the address above it read as an empty list, and Steel refused to start with "alerts.sms is enabled with no numbers in 'to'" -- correctly, since an alerter with nobody to tell is worse than none. It cost a deploy, which rolled itself back. Both now go through `strings_in`, so a third caller cannot make it three. Proven end to end afterwards: a deliberately-failing peer was watched for three rounds, and the refusal went out by mail (accepted by the recipient's MX) and by text (ClickSend SUCCESS) within a second of each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21457replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Spawn the watcher on the runtime handle, not on a runtime that is not current yet

I put tokio::spawn eighty lines below a comment that says, of the traffic and host samplers, that they are spawned inside Server::start and not here because this function is sync -- the runtime is built but not entered, so tokio::spawn would panic with "there is no reactor running". It does. rt.spawn is what the sealed-start alert twenty lines further down already uses, in this same function, for this same reason. The cost of not reading that comment: karri went into a systemd restart loop and its sites were down for under a minute before the rollback. The code compiles perfectly. It fails only when a server is actually started, which is the point worth keeping -- a change to this function is not tested until something has been started with it. NOT YET PROVEN BY A START. A scratch Steel exits cleanly before reaching this line, having no certificates and a sealed database, so the fix is reasoned from the established pattern rather than observed. It goes back on a live host only after it has been seen to start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21453replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Machines watch each other, because a dead one cannot report its own death

alert.rs has said this in its own header since it was written: Steel reports on itself, so a Steel that is wedged or gone sends nothing, and silence is indistinguishable from health. On 2026-08-10 that stopped being a caveat. A payments gateway exited on its own and was down for about fifty minutes while the websites in front of it served perfectly. Nothing said a word, because the only thing positioned to notice was the machine that had died. So the question is inverted. Do not detect a failure -- require a success, on a schedule, from somewhere else, and treat its absence as the alarm. A mesh, not a monitor. Every node watches every other node it is told about and any node can raise the alarm, because a monitoring server is one more single point that fails quietly. Adding or removing a machine is one line of configuration and nothing else: no code, no central registry, which is the whole reason the peer list is data. Duplicate alarms are left in deliberately. Two watchers seeing one outage send two messages, and suppressing that would need the watchers to agree with each other -- a protocol, which is a thing that can fail and take the alarm with it. Two true messages cost a glance; one suppressed by a consensus that broke costs an outage. It does not restart anything. A watcher that repairs can flap a service in a loop at three in the morning and hide the fault it was built to reveal, and the decision to restart a payments process belongs to somebody who has read why it stopped. The second channel is a text message, through fe2o3_net::sms. Mail and SMS fail for different reasons -- mail needs a working MX and a mailbox somebody reads, a text needs a funded account and a carrier -- and two channels that fail independently is the entire value of having two. Both legs are attempted and neither can prevent the other. Severity decides which: a peer going down reaches a phone, an unseal does not, because an operator who is texted about routine events stops reading the texts. The credential is read from the environment and never from the configuration file. A config is copied between machines, pasted into a chat window to ask why a server will not start, and committed by accident; an environment variable is none of those by default. Heartbeat exists so the path is exercised. An alerting route used twice a year is broken when it is needed -- an expired credential, a rotated key, a changed number -- and it is discovered during the incident. If the boring messages stop, the alerting has failed and not the estate. Both new config blocks are #[optional], per the note the alerts block already carries: two live production configurations have never heard of either, and a required field would make both invalid the moment they read this binary. 230 Steel tests pass. The one failure, publish::page::test_an_unconfigured_mark_ is_the_title_27, fails identically on unmodified HEAD and is not from this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21451replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

31 operations, since the previous mark · compare with the head
+Send one text message to three gateways, without dialling any of them

A text message is the only alert channel that reaches a person with no data connection, and a host that has just gone dark is exactly when neither push nor mail may be to hand. So this is the last leg of an alerting path rather than a messaging feature, and it is deliberately small: one message, one receipt, no delivery webhooks, no inbound, no scheduling. Provider::request returns the parts of a call and stops, as Engine::request does beside it. The caller dials, because the caller is the one that resolves a host and refuses a private address, and a module that opened its own socket would walk around all of it. Every provider here reads its credential from an Authorization: Basic header, and that is the entry requirement rather than a coincidence. A vendor whose scheme puts the secret in the request BODY is excluded, because bodies are logged, echoed in error messages and captured by proxies in ways headers are not. Vonage is the notable absence on exactly that ground. The test asserts the property per provider rather than over a list, so a fourth arm added without thinking about it fails rather than quietly widening the promise -- and it also asserts the credential IS present, so it cannot pass by the secret having been dropped altogether. Two things a daticle taught me while writing the parser. It knows its own width and prints it, so reading a segment count by parsing Display returned zero for every vendor that sends the count as a number rather than a string; the widths are now matched out explicitly. The same applies to any scalar a receipt carries through, which is why a price is never parsed into a figure this module would then be claiming to understand -- it is passed on exactly as the vendor wrote it, in whatever currency the account is billed in. An error document is checked before the success fields, because two of these vendors answer a rejected credential with HTTP 200 and an object explaining themselves. A parser that read the receipt first would report a missing field where the vendor had written the sentence that says the account is out of credit. 284 tests pass in fe2o3_net, 7 of them new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUPCWxGhSwJtmzscpHRnMd

51 days agor1870400018:21419replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Ask four search engines the same question, without dialling any of them

A tool that searches has to name an engine, and the engines agree on nothing: one is a GET with the query escaped into the path, three are a POST with it in a JSON body, and each reads its key from a differently spelled header. So this is not the shape llm.rs has, where three providers speak one dialect and differ only in address. Each arm carries real code, and SearchResult -- title, url, snippet, age, every field a string -- is the narrow common shape all four are flattened into. Engine::request returns the parts of a call and stops. The caller dials. That is the point rather than an oversight: a caller that resolves a host, refuses a private address and repeats the refusal on every redirect hop has built a gate, and a module that opens its own socket walks around it. There is deliberately no convenience here that sends. parse is forgiving by the row and strict by the document. One malformed row out of twenty is not a reason to answer with nothing, so a result with no title or no url is dropped and the rest returned; an error document is an error, naming the engine and repeating what it said, because that text is the only place a rejected key is ever explained. Four vendors wrap that message four ways -- a bare string, an object with a detail, an object with a message, an object holding an object -- and each is unwrapped rather than any one assumed. age goes through exactly as the engine wrote it. The four disagree about what it measures -- publication, last crawl, or how long ago either was -- so it is never parsed into a timestamp, and a row that gave no date gets none. Two engines have no scholarly index and say so through supports(), which refuses the request here instead of sending one that comes back rejected. The tests found one thing worth recording. A bare JSON number decodes to the narrowest kind that holds it, so the U64 arm that read one engine's bare-year date never fired: 2017 arrives as a U16 and the date came out empty. Every integer width is answered now. Each test was also run against a deliberately broken copy of the code it covers, which caught a second defect in the tests themselves -- the error-document test passed with error detection removed entirely, because the fallback that dumps an unrecognised body happens to contain the message the test was looking for. It now also insists on the absence of a wrapper key that only the raw dump would carry.

51 days agor1870400018:21415replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say what a file is before showing it to somebody

Every program that puts a file in front of a person answers the same question first, and there are only two places an answer comes from. The name is what somebody typed and the bytes are what is there, so they part company whenever a file has been renamed, exported wrongly or truncated. identify() asks both and reports the disagreement rather than quietly picking one -- a file called .png holding PDF bytes is how you find a broken export, and hiding it helps nobody. The mistake this is written to stop: reading unknown bytes as UTF-8 with a lossy decoder and showing the result. Every byte that is not valid UTF-8 becomes U+FFFD, so the reader gets a screenful of replacement characters and no hint that anything went wrong. The program looks broken rather than the format looking unsupported, and those are very different bug reports. Fifty-one formats with their signatures, media types and classes. One heuristic, looks_like_text, with its limits written on it -- including that a prefix cut mid-character is not evidence of binary, which is a defect that shows up once every few hundred files and never on demand. Checked against the file command over seventeen real files of seventeen formats; sixteen agree exactly. The seventeenth is deliberate and recorded: file reports a TrueType font as the generic font/sfnt while describing the same bytes as "TrueType Font data", and RFC 8081 registers font/ttf for what the 00 01 00 00 version tag identifies.

52 days agor1870400018:21411replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Make newsletter mail look like mail a person sent

A confirmation that lands in spam is a subscription that never happens, so the three things a large provider weighs were all wrong at once. - **No Message-ID.** Every message needs one; without it a message cannot be threaded or de-duplicated, and the large providers read its absence as the mark of something not sent by real mail software. Added to all three message kinds, unique per message and in the sending domain. - **The signature named the wrong domain.** One host serving several domains signed every message as whichever domain the mail config named, so a message From need2know.ai carried d=elearnity.oxegen.io -- unaligned, and therefore worth nothing to the receiver deciding whether to believe it. A DKIM key is not bound to a domain; what binds them is the record published under it. DkimSigner::for_domain re-derives the same key for the domain the message is actually from, and the caller must have published it there. - **No one-click unsubscribe.** The newsletter advertised a List-Unsubscribe URL but not List-Unsubscribe-Post, because the endpoint took only a GET. It now takes a POST at the same address with the token in the query, so the header can promise what the server actually does. Also, two things a reader sees. The subscription pages -- reached from a link in an email, which is the end of a road -- now offer the way back to the site. And the masthead's home link says what the site is called rather than what it calls its posts, which on any site setting `home` printed the same word twice, side by side, going to two different places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CN2RGNRPaRpURe2SL6nMpM

52 days agor1870400018:21406replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

26 operations, since the previous mark · compare with the head
+Take the address alone into the SMTP envelope

`newsletter_from` is documented as `README <news@oxedyne.com>`, and that whole string went into `MAIL FROM:<...>`. A receiving server refuses that reverse-path with a 5xx; this module reads a 5xx as a permanent failure and answers it by suppressing the subscriber. So the documented shape of the field would have marked every address it was ever used with as bounced, and never mailed any of them again -- a configuration that looks right, sends nothing, and quietly burns the list as it goes. The header keeps the display name, which is what a reader sees. Only the envelope is stripped to the address. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CN2RGNRPaRpURe2SL6nMpM

52 days agor1870400018:21379replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Put a honeypot and a rate limit in front of the newsletter sign-up

The subscribe endpoint is unauthenticated and its effect is a piece of mail to an address the sender chose. Bare, that is a mail-bombing tool wearing the site's own domain, and the cost lands on the sending reputation every later confirmation depends on -- not on the disk. The comment endpoint next door already had a honeypot, a challenge and per-sender rate limits; this one had none of them. Three layers now, and none of them tells the sender which one caught it: - The field no person fills in. Filled means a machine filled it, and the submission is dropped and answered with the ordinary page, since telling a bot it was spotted only teaches it which field to leave alone. - A limit per sender, keyed on a salted hash of where the request came from. Over it, the same page again: a form that says "too often" tells a script exactly what it has found. - Double opt-in, which was already here, capping the worst case at one message per address rather than a correspondence. rate_allows gains rate_allows_at so the sign-up counter is kept apart from the comment counter: a reader who has just commented has not thereby spent their sign-up. from_hash gains hash_with for the same reason at the hash -- one address must not produce the same value in both counters. The two new config fields default to limiting rather than not, because the failure worth designing against is the block that predates them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CN2RGNRPaRpURe2SL6nMpM

52 days agor1870400018:21375replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+Read an AVI's size and running time

The other container a family library holds, and one nothing in the workspace could see. The header list is at the front, so a caller holding a sniffing buffer gets the same answer as one holding the file, and no frame is decoded -- what is inside is Motion JPEG or DV and that is the caller's business. The stream header's length is preferred to the main header's frame count: the latter is a single 32-bit field written before the file was finished, and the OpenDML extensions leave it nought once a file passes four gigabytes. Held to ffprobe over 531 real films: all 531 agree on size and running time, worst difference 144 ms, none refused.

52 days agor1870400018:21358replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Put rustls on ring, which this crate already carried

tokio-rustls was taken with its default features, so every caller linked aws-lc-rs -- a second cryptography library, in C -- including callers that serve plain HTTP and use none of it. Cross-compiling it for Windows builds its POSIX threading backend for a target with no pthreads, and the link then wants the posix mingw and -lwinpthread for fifteen symbols. fe2o3_steel already pinned rustls to ring, so this brings one crate into line rather than setting a policy. The manifest was only half of it. cargo check across the workspace passed while tls.rs still named crypto::aws_lc_rs, because a workspace build unifies features and another crate kept that module alive. It failed only when building a downstream application outside the workspace.

52 days agor1870400018:21352replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Merge branch 'wt-cabac'52 days agor1870400018:21347replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Say that both entropy coders are now written

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21346replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Ask FFmpeg for the picture the decoder actually draws

Nine 4K films code all-intra pictures interleaved with bidirectionally predicted ones, so the first picture shown is not the first sync sample. Comparing the two decoded different moments and disagreed in every sample with nothing to say why. Select the first intra picture on both sides. A picture's slices must tile it, so a macroblock left undecoded is now refused by name: for an arithmetically coded slice that is the only sign a desynchronised coder gives, since it goes on answering bins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21327replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Compare against the coded picture, not the container's crop

A film may carry a clean aperture in its sample description and FFmpeg crops to it; a decoder produces what the coded stream describes. One film in the corpus is coded 1440 by 1080 and asks to be shown as 1308 by 980. A size disagreement is now recorded as a difference rather than ending the survey, so one odd film cannot stop the rest being measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21321replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Count how many films the decoder can draw

A corpus probe that reports rather than asserts: how many of a library's H.264 films give up a first frame, split by entropy coder, and every refusal by name and count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21318replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Read a slice with the arithmetic entropy coder

CABAC, clause 9.3: the decoding engine, the context variables an intra 4:2:0 slice draws on, the binarisation of every syntax element above a block, and the reading of a block of coefficients. The 261 initialisation pairs are transcribed by hand and held to the specification text entry by entry, along with rangeTabLPS, the state transitions and the scan position mapping of Table 9-43. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21315replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Merge branch 'wt-posters'52 days agor1870400018:21305replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+fe2o3_graphics: read the header of a picture that is not an IDR, and the window a film is shown in

Three things a film has and a photograph does not, each of which made the reader hand back a picture that was refused or wrong. A film's first frame is very often a clean random access picture rather than an IDR. It is decoded exactly as one -- it references nothing before itself -- but its slice header carries the picture order count and the reference picture set that an IDR's does not, because the pictures after it may reference what it names. Read as though it were an IDR's, the header comes apart from that field on: 67 films in a library of seven thousand were refused for naming five pieces of a picture thirty-four rows deep. The fields are held to FFmpeg's own trace of the same bytes, element by element. The sequence parameter set's video usability information is now read as far as the default display window, which also yields the two things a colour conversion should not be guessing: the range and the matrix. And a film's clean aperture is read from the container. A phone stabilises a film by coding a picture larger than it shows and moving a window about inside it, and the window is a `clap` box in the sample entry. Ignoring it hands back nine per cent more of the frame than the film shows. Twenty-five of the thirty-three films sampled from the corpus carry one, and with it applied thirty-two of them match FFmpeg sample for sample where seven did before. The reserved slice header bits are also read where the specification puts them, before the slice type rather than after it.

52 days agor1870400018:21304replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

57 operations, since the previous mark · compare with the head
+fe2o3_graphics: read the four matrix entries that rotate, not four in a row

A track header's matrix is a, b, u, c, d, v, x, y, w. The reader took the first four for a, b, c and d -- but `u` sits between b and c, and `u` is nought in every matrix a camera writes, so the pattern for a quarter turn could never match. Every rotated film in a library of seven thousand was read as upright. The test agreed with it because the test wrote the matrix the same wrong way round, which is a test proving that a reader agrees with itself. It now writes the entries where the specification puts them, and a real film out of the corpus was the thing that disagreed. `rotation_of` is now a function of its own, so the same reading serves a caller holding a track header without a whole film behind it.

52 days agor1870400018:21246replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+fe2o3_graphics: read a picture cut into slices, and one coded without wavefronts

A photograph is one slice coded in wavefronts and every one in the corpus is, so that was the only shape the decoder read: a picture whose slice carried no entry points was refused for having only its first row findable, and a second slice segment was refused by name. Both are ordinary in a film. Without wavefronts a slice is one arithmetic decoder from its first block to its last, with the contexts carrying across row boundaries rather than being reset at them. And a picture cut into slices is several, each beginning at the block its header names, each starting the arithmetic decoder afresh -- and each predicting from nothing outside itself, which is the point of cutting one, so availability gains a second half beyond decoding order. What is still refused is refused by name: a dependent slice segment, the pair of slices and wavefronts together, and a picture whose loop filters are not to run across the boundaries between its slices.

52 days agor1870400018:21238replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

37 operations, since the previous mark · compare with the head
+fe2o3_graphics: say how the film measurement is getting on while it runs52 days agor1870400018:21200replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_graphics: ask a real library how many of its films give up a poster

Nothing here holds a film and nothing here uses a fixture. The corpus is somebody's actual library, written by a dozen phones and cameras over twenty years, and FFmpeg -- which has no idea this crate exists -- is the oracle for every sample. The first test reports rather than asserts, because how many films can be drawn today is a measurement and not a promise. The rest assert: a sample of posters must match FFmpeg sample for sample with `-noautorotate`, a film the container says is turned must come out of FFmpeg's own turn transposed the way the reader says, and reading an index and one sample must read less than the whole file.

52 days agor1870400018:21197replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_graphics: one planar picture, and one way out of it into colour

Both video decoders here produce the same thing -- three planes, the colour ones at half size -- and for a while they produced it as two unrelated types with the conversion into red, green and blue written against only one of them. So an H.264 frame was a picture nothing could draw. `yuv` names the shared form: `Frame`, `Plane` and `rgb` are the hevc ones, and an H.264 picture reaches all three through `From`, which widens its eight-bit samples. Nothing is a second implementation of anything, and no existing signature moved.

52 days agor1870400018:21194replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_graphics: crop a coded picture to the size it is shown at

A coded picture is a whole number of coding tree blocks and a shown one is not: a 1920 by 1080 film is coded 1920 by 1088, and the eight rows the encoder filled to reach the block boundary are not part of the film. The HEIC path never met this because it crops to the size the container declares, but a film has no container property to ask -- only the sequence parameter set's conformance window. `picture_shown` is `picture` with that window applied. `picture` is left as it was, because cropping there would take the same rows off twice on the HEIC path.

52 days agor1870400018:21190replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+fe2o3_graphics: read a film's index out of a file, without holding the film

The sample table says where a film's first frame is, and a caller wanting one frame of a four-gigabyte film should never have to hold the other four gigabytes to get it. `moov_of` lifts the movie box by walking the top-level box headers -- eight bytes and a seek each, an `mdat` stepped over by its declared length and never touched -- and `Film::of` indexes a track from it. `Film::read_sample` then fetches the one sample the index names. `mvhd_of` is the cheaper half for a caller that only wants to know how long a film runs: the movie header alone, without a long film's sample tables, and `movie_ticks` reads the timescale and the duration out of it. Every downstream reader of running times had a copy of this walk; now there is one.

52 days agor1870400018:21184replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Merge branch 'wt-names'52 days agor1870400018:21181replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Tidy a label somebody typed, once rather than in every caller

Three things in one downstream app want the same five lines -- an album's name, a person's name and a saved search's: drop control characters, because a newline in the middle of a name is a paste gone wrong; trim the ends; bound the length in characters rather than bytes, so a limit means the same thing in every script. Nothing is answered where nothing readable is left, since the caller is the only one that knows what to say about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTgzDMQVmSpnWuELRfx9JM

52 days agor1870400018:21180replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_steel: keep the pseudo-terminal on the platforms that have one

The WebSocket terminal bridge is the only thing in the workspace that reaches for nix, and it rests on a pseudo-terminal whose slave end becomes a child's three standard streams -- a pair of ideas Windows has no counterpart to. The bridge is therefore gated to Unix and nix moves under a cfg(unix) dependency, so the crate no longer carries a POSIX-only dependency on a platform that cannot use it. Where there is no pseudo-terminal the bridge completes the handshake and then refuses by name. The handshake happens first on purpose: a socket closed before it opens carries no reason, and a browser reports only that the connection failed.

52 days agor1870400018:21177replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Decode a film's first frame

Merges the H.264 intra decoder and the MP4 reader that finds what to hand it, together with the parameter-set selection that lets the existing HEVC decoder reach a film as well as a photograph. Verified over the whole library before merging: 1,658 H.264 films, 25,889 header fields agreeing with FFmpeg and none disagreeing, 711 of 711 CAVLC films exact sample for sample, and 947 CABAC films refused by name. 267 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21169replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Choose the parameter sets a slice names, not the last ones read

`picture` kept the last sequence and picture parameter set the configuration record carried. A photograph out of a camera carries one apiece, so either would do and nothing showed. A film carries several, and a slice names which it was coded against -- so four films in ten were refused for referring to a set that was in hand all along. Both are now chosen by identifier: the picture set the slice names, and the sequence set that one belongs to. Reading which set a slice wants needs no set, because the identifier is the third element of the header and nothing before it depends on one, so `slice_pps_id` reads it and the choice is made before the header is parsed. Measured over the real library, on a decoder written for still photographs and pointed at films: 66 of 150 HEVC films drew a first frame before, and 142 of 150 after. What is left is seven pictures cut into more than one slice and one film at ten bits, both refused by name. This matters more than it looks. Three quarters of the 7,242 films in that library are HEVC, not H.264, so the decoder that was written for HEIC already reaches about five thousand of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21168replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+fe2o3_graphics: two uses of the question-mark operator, written out

House rule, and the two that slipped in were on an Option rather than an Outcome, which is why they read as ordinary code.

52 days agor1870400018:21157replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+fe2o3_graphics: say what a caller still has to do with the planes

The colour conversion out of 4:2:0 is the same arithmetic for H.264 and HEVC and lives in one of them, over a picture type the other does not share; and nothing in a coded picture says which way is up, so the track header's angle is the caller's to apply. Both are recorded where a caller will look for them.

52 days agor1870400018:21155replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+fe2o3_graphics: each slice asks the deblocking filter for itself

Found by decoding all 1,658 H.264 films against FFmpeg: 707 of the 711 CAVLC ones matched sample for sample, and four files -- two films, each stored twice -- differed by at most five levels, starting three rows above the line where their second slice begins. That is the filter's exact reach across a horizontal edge, and the cause was that the deblocking disposition was read from the first slice and applied to the picture. Both films set `disable_deblocking_filter_idc` to 2: filter everything within the slice and nothing across its boundary. Each slice's thresholds are its own too, and are now carried per slice rather than per picture. The corpus comparison is now a gate rather than a report: any film that decodes to something other than what FFmpeg decodes fails it. A picture that is nearly right is a wrong picture.

52 days agor1870400018:21153replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+fe2o3_graphics: say what is decoded and what is refused, and read a film's rotation

The module doc now states plainly that the CAVLC half is complete and verified and the CABAC half is not written, that this is 711 films against 947, and what writing it will need — including the one thing worth knowing in advance, that Tables 9-12 to 9-33 cannot be parsed out of a text rendering the way Table 9-5 was, because their digits wrap across lines. Beside it, two tests on the container: a film the writer wrote is read back sample for sample, and a track header's transformation matrix is read as the angle it codes. Both were proved against the broken behaviour first — the rotation test fails when the matrix offset is moved by one field. The deblocking filter's strength-2 machinery is removed rather than kept unreachable: the coefficient and motion tests come after the intra test, so an all-intra picture never reaches them.

52 days agor1870400018:21124replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+fe2o3_graphics: a film's first frame, decoded and held to FFmpeg

The macroblock walk, the deblocking filter, and the CAVLC path end to end. Every H.264 film tried so far comes out identical to FFmpeg's own decode, luma and chroma, every sample. Four faults found by that comparison, none of which fails on its own: - The coefficients of a 4x4 block were never inverse-scanned, so every one sat at the wrong frequency. - §9.2.4 lays the levels out from the *last* one read, walking forwards through the block; starting at the far end and walking back is plausible and wrong. - A block's predicted mode was read from the picture rather than from the macroblock being built, so every in-macroblock neighbour offered DC. - §8.3.1.1's `dcPredModePredictedFlag` is one flag, not two: if *either* neighbouring macroblock is unavailable, *both* modes become DC. Taking the minimum of the available neighbour and a notional 2 differs on every block along the top and left edges of a picture. And one in the oracle rather than the decoder: a phone writes the angle it was held at into the track header, and FFmpeg turns the picture on the way out. At ninety degrees the turned picture has exactly as many samples as the untured one, so nothing but the samples says so. `mp4::Film` now reports the rotation, and the comparison asks FFmpeg not to apply it.

52 days agor1870400018:21108replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_graphics: H.264's transforms, intra prediction and CAVLC tables

The three layers under the macroblock walk, each held to the published specification rather than to itself. The scans of Tables 8-13 and 8-14, and the norm adjustment matrices of equations 8-315 and 8-318, are re-read out of Rec. ITU-T H.264 by the tests. So are all 372 entries of Table 9-5, which is far too many to type in: the tables were parsed out of the document, and every column is separately asserted to be a prefix code, which is the property a codeword one bit short or one place out of its column almost always breaks. Two things recorded because they ruin a picture without failing: - An absent scaling list means "inherit", and at the head of each fall-back chain it means the default matrix of Table 7-3, which is not flat. 33 films in the corpus carry lists. - Intra_8x8 predicts from *filtered* reference samples. Leaving the filter out gives a picture right in its large shapes and wrong in every block's texture.

52 days agor1870400018:21094replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+fe2o3_graphics: a film's index, and the H.264 headers it points at

An MP4 reader beside the writer, and the H.264 parameter sets and slice headers that come out of it. The reader holds an index rather than a film: the corpus this was written against runs to 91 GB and one film in it is 4 GB on its own, so a sample is a span and the bytes are the caller's to fetch. Held to FFmpeg's own header tracer over the library, which is a separate parse of the same bytes by a separate implementation. Two faults it caught, both of which produce a plausible answer rather than an error: - A sound track's sample entry was read as though it were a visual one, so a film with audio in it was refused for the shape of a track nobody asked about. - QuickTime puts a second `hdlr` inside `minf` naming the data handler, and taking whichever `hdlr` came last decided a 2003 camcorder's film had no video in it.

52 days agor1870400018:21084replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Say what the decoder actually refuses, and stop naming an application

Four documents had drifted from the code they describe, and each was found by reading them against it rather than by a test. The decoder's own header still listed as "still to come" the quadtree, the residual coding, the transforms, the prediction, both loop filters and the colour conversion -- all of which it does. Its refusal list was wrong in two directions at once: it claimed to refuse scaling lists, which it reads and applies because two in five of the corpus carry their own and the default lists are not flat; and it claimed to refuse palettes, cross-component prediction and residual rotation, which are enabled by parameter set extensions this reader stops before. Such a stream is neither refused nor decoded correctly -- it is outside what is read at all, which is a weaker guarantee than a refusal and now says so. The container module said it "decodes nothing either", which stopped being true when heif::decode was written. The whole-image test said the two implementations "will not agree sample for sample and are not asked to", on the grounds that the loop filters were not yet run; they are, and the test asserts the worst difference over every tile is nought. And a doc comment in heif named an application, which this library's documentation does not do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21077replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+Decode a category detector's heads into boxes

The network gives three heads a class score and a box per position, and the box is not four numbers: it is four distributions over eight bins, and the distance to each side is their mean. So a side is a softmax and then a dot product against the bin indices, times the stride. The positions are not the centres of their cells. They sit half a sample short, at i*stride + (stride-1)/2, which every reference implementation of this model does and which is the easiest thing here to get wrong. Taking the centre instead does not merely shift a box: it changes which boxes survive the suppression, and the test that proves this catches it by finding fourteen objects where there are fifteen. The suppression is deliberately blind to the category. Two boxes on one animal, one calling it a dog and the other a cat, are one animal; keeping both would report the network's disagreement with itself as a pair of findings. On this library that disagreement is not hypothetical -- the same black dog is called a cat in three photographs out of a sample. Held to the reference's own decoded boxes on six real photographs: same count, same categories, same scores to a part in a hundred thousand, every edge inside a quarter of a pixel. Both faults above were measured against it first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21044replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Split, join and shuffle the channels, and a whole detector agrees

A category detector's backbone splits its channels, sends half down a branch, joins them again and then interleaves them, which a channels-first model writes as a reshape into five axes, a transpose of two of them and a reshape back. In a channels-last layout none of that touches the spatial axes and the whole run is a permutation of the innermost one, so the two reshapes come to nothing and the transpose is the permutation. That is matched as a run of three nodes rather than node by node, deliberately. Each of those operators alone would need a real permutation of a channels-last activation, and it is only together that they are free; a graph using one on its own is refused rather than quietly mishandled. The same goes for the reshape and transpose a detection head ends with, which together are one reshape that moves no value. Held against OpenCV's own DNN engine on six real photographs, one letterboxed the other way up: 225 nodes and six outputs, worst disagreement 7.2e-6 on values of order 4, which is two engines summing a convolution in different orders. The test was proven on the broken cases first -- dropping the channel shuffle differs by 16.9 and reading the resize by the other coordinate rule by 2.7, both six orders of magnitude above the noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21037replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Pool and resample over any shape, not just the two the face models use

The maximum pool was two by two, stride two, no padding, and the resize was an exact nearest-neighbour doubling, because that is all YuNet asks for. A category detector asks for a three by three pool at stride two with a pad of one, and for a bilinear resample both up and down, so both kernels are now general and the two special cases fall out of them as parameters. The mode and the coordinate convention are read from the graph rather than assumed. They differ between the two models -- one writes nearest and asymmetric, the other linear and pytorch_half_pixel -- and half a sample of disagreement moves every box a detector predicts. Padding contributes nothing to a maximum rather than contributing zero. Zero is not the identity of a maximum, and after a leaky rectifier a whole window can be negative, so a padding zero would be the answer at every edge. There is a test that fails on exactly that. The two tests holding the face detector and the embedder to what tract answered still pass, which is what says the generalisation left the existing models alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JPpo3dbkNNo28cwZZJ9Siv

52 days agor1870400018:21021replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

46 operations, since the previous mark · compare with the head
+jpeg: strip metadata without touching the image

A photograph off a phone arrives with an Exif block naming the camera, the moment and, very often, the coordinates of whoever pressed the shutter. Publishing the file publishes all of it, and the caller has to remember to prevent that. strip_metadata walks the marker segments and drops the ones that describe the picture rather than encode it, leaving the entropy-coded scan untouched. The result decodes to identical pixels: verified against an 11.6 MB iPhone photograph carrying eleven Exif fields and a GPS ifd, which came out with none of them and no pixel difference. JFIF, ICC and the Adobe colour transform are kept, since dropping those changes how the file renders. Everything else in the application range goes, along with comments -- which takes XMP, IPTC, maker notes and any JUMBF or C2PA assertion with it, so a caller relying on embedded provenance must read it first. The doc comment says so.

53 days agor1870400018:20974replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_graphics: an image's size, without decoding the image

`jpeg::dimensions` already existed; `png::dimensions` did not, so a caller that only needed to know how big a PNG was had to decode the whole of it. That is the wrong shape whenever the answer is used to decide whether to keep the pixels at all — costing a megabyte of allocation to find out that a megabyte is too much. It reads the signature and the mandatory first IHDR chunk, twenty-nine bytes whatever the file weighs, and validates through the same `decode_header` the decoder uses so the two cannot disagree about what a valid header is. The caller that wanted it prices an image in visual tokens before sending it to a model, where the cost is a function of width and height and nothing else.

55 days agor1870400018:20971replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_test: a scratch directory that is not in tmpfs and does not accumulate

Test fixtures across three workspaces were writing into std::env::temp_dir(). On the machine this was found, /tmp is a tmpfs, so every fixture is resident memory the test binary never gives back -- 34,795 directories at the point of measurement, 67,413 at its peak, and nothing had ever removed one. scratch_dir(label) puts them under the user cache instead, refusing any candidate under temp_dir() or literally under /tmp rather than silently falling back to it. The name carries the owner's pid, so bounding is by sweep rather than by Drop: a Drop is impractical where the fixture holds a database whose bots run on their own threads, and where the caller keeps no guard at all. The first call in a process removes what no live process owns. Uniqueness is settled by the filesystem -- create_dir, not create_dir_all, retrying on AlreadyExists -- because nanoseconds do not survive two processes and a pid does not survive two calls. Ten more temp_dir() fixtures remain in fe2o3 itself and are named in the module doc for a later pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNA7XFwaxJFGyRsXJm3CQo

58 days agor1870400018:20968replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+fe2o3_core, fe2o3_jdat: three imports the macro_use already supplies

byte.rs, path.rs and version.rs each imported a macro they invoke, and each resolves without the import because both crates declare their macro module `#[macro_use]` ahead of everything else. Stable is silent about it; nightly is not, and a warning on every downstream build is how a real one goes unread. Checked before deleting rather than after: every invocation is at module top level or in a plain impl, none cfg-gated, and neither crate has a [features] table, so there is no configuration under which the import becomes load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNA7XFwaxJFGyRsXJm3CQo

58 days agor1870400018:20963replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_text: a regular expression engine, and glob matching

An app wanting to search a tree needed both, and either written in the app would have been the wrong place: a second caller for "does this line match" and "does this path match" is not hypothetical. regex is a backtracking matcher over an enum AST: literals, classes with ranges and negation, the \d \w \s shorthands and their negations, anchors, word boundaries, groups and non-capturing groups, alternation, and the repetitions greedy and lazy. No captures, deliberately -- a line search never asks which part matched, and leaving them out makes the matcher one recursive walk rather than a machine with a capture stack. glob takes *, ?, **, character classes and brace alternation. A pattern with no slash matches the basename, one with a slash the whole path, which is what a person means by both. Bounded against its own recursion, and the bound is measured rather than guessed. The first draft recursed once per repetition iteration, so .* over a minified 667 kB single-line file overflowed the stack -- found because breaking the empty-repetition guard did not fail a test, it aborted the test process. Single-character repeats are now counted in a loop, and what remains is bounded by stack bytes actually consumed rather than by a frame count: measurement put a debug frame at 5.4 kB against a fraction of that optimised, so no fixed count is both safe and useful. A line the matcher gives up on is reported as undecided, never as a line that did not match. Verified against grep -E rather than against its author: eight patterns over 25 files of real source, 1,675 matches, no disagreement. Twelve properties were each proved by breaking the code first -- . crossing a newline, lazy behaving greedily, alternation preferring the longest arm, case folding missing inside a class, ** matching no segment, a bare pattern tested against the whole path -- and confirming the test failed before it passed. No new dependencies.

59 days agor1870400018:20959replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+fe2o3_tui: box-drawing glyphs, and scrollback that survives a resize

Two gaps in the terminal model, both of which showed as a screen a reader could not trust. The DEC special graphics set was ignored, so an ncurses programme drawing a box got letters where lines belong -- q for a horizontal rule, x for a vertical one. G0 to G3 designation, the locking shifts, and save, restore and reset now carry the character sets, and the mapping applies only to the printable ASCII range so UTF-8 passes through untouched. Columns rewrap on resize rather than truncating, which the previous commit ruled out and this one answers rather than overrides. The objection was that a continuation flag means nothing once a full-screen application has drawn over the grid, so a reflowing terminal scrambles an editor. It still would: the flag is set in one place, cleared by an erase that reaches the row's end, and the alternate surface is truncated and never rewrapped. What rewraps is the history a person scrolls back through, which is where the loss was felt. The oracle earned itself a third time, and this round it disagreed with tmux about tmux. Reflowing a mixed-width line, tmux 3.6 breaks aa世世世 after four columns of six and after six of eight -- neither being where tmux itself breaks when the same bytes are printed at that width. Printing is the definition a rewrap has to meet, so those cases compare against the print path, and the disagreement is written down rather than quietly adopted. It also caught the column left empty ahead of a double-width character that will not fit: indistinguishable from a printed space, and a rewrap eats a column. That padding is now its own state, which the renderer will need to know. Sourced throughout: every expectation is transcribed tmux output, never typed. The two that are not -- DECSTR resetting the sets, and the wide-character rewrap rule -- come from the VT510 manual and ECMA-48 and say so in the code. Sixteen rows of a real curses programme match cell for cell at four chunk sizes, and 116 mixed-width reflow comparisons match with none outstanding. tests/draw.rs and tests/main.rs compile again, having imported modules that moved under lib_tui long ago. One expectation changed rather than the code: AbsRect::clip answers None where it once gave a zero-width rectangle, and None is what tbox.rs matches on. No new dependencies.

59 days agor1870400018:20951replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

133 operations, since the previous mark · compare with the head
+fe2o3_tui: a terminal model — VT parser, screen, scrollback

Bytes from a pty in, a grid of cells out. A VT500-shaped state machine over C0, CSI, OSC and the string escapes, feeding a screen with attributes, a cursor, a scroll region, tab stops, an alternate surface, bounded scrollback and a damage model so a renderer repaints only what changed. Generic by nature and so it lives here rather than in the app that wanted it: a second caller for a terminal model is not hypothetical, and the crate is already the terminal one. Verified against a real terminal rather than against its author's reading of the specification: 37 handwritten cases compared with tmux, plus nine recorded real programmes -- ls --color, top, man, grep, a progress bar, vim and less -- replayed through both and compared cell by cell, fed in varying chunk sizes. The oracle earned itself twice. It caught DECSTR homing the cursor, which several references describe and neither tmux nor xterm does. And it disagreed about lines scrolled out of a mid-screen region, where tmux pushes them into history out of order; a line is kept here only when the region starts at row 0 on the primary surface, and the disagreement is written down rather than quietly resolved. Columns truncate rather than reflow, deliberately: reflow needs a continuation flag per row that means nothing once a full-screen application has drawn over the grid, so a reflowing terminal scrambles an editor. Bounded by construction throughout -- a fixed parameter array, string payloads dropped past 8 kB while the scan for the terminator continues, scrollback capped and evicted. A 10,000-line flood costs one repaint of 24 rows. No new dependencies.

59 days agor1870400018:20817replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+A frame's declared size is checked before it is believed.

A websocket frame states its own payload length in as much as 64 bits, and the reader allocated that many bytes before reading them. Ten bytes on the wire -- two of header and eight of length -- and any fe2o3 service reading frames attempts an allocation of whatever number the sender chose. The test written for this aborts the old reader outright: "memory allocation of 9223372036854775807 bytes failed", SIGABRT, from a peer that sent ten bytes and then nothing. `WebSocketLimits` bounds it, and carries two numbers because one bounds nothing. `max_frame` bounds a single frame; `max_msg` bounds the assembled message, since a message may arrive as any number of continuation frames each comfortably under the frame bound, and they are joined in one buffer. Defaults of 16 MiB and 64 MiB; `read_message` takes them as an argument and `WebSocket::with_limits` replaces a socket's pair. There is no way to say "no bound". Three things about the check rather than the numbers: - It is made on the 64-bit value that came off the wire, before that value is narrowed to a `usize`. Narrowing first truncates on a 32-bit target, where a declared 2^32 + 1 bytes would pass any bound at all as one byte. - It precedes the allocation, not the read. An over-limit frame costs the header it was declared in; nothing is reserved and the payload is never read. The test asserts the payload bytes are still unread when the error comes back. - It is answered. The error carries `TooBig`, `WebSocket::read` sends a close with status 1009, and `listen` ends the connection instead of counting the breach as one error among a permitted few -- it has to, because the refusal leaves the stream part way through a frame and there is no boundary left to resynchronise to. The tag is repeated on the `res!` wrapper deliberately. `Error::tags` reads the outermost frame of an error and not the chain beneath it, so a tag only on the inner error is a tag no caller will find. Also here: a control frame declaring more than 125 bytes is refused, which RFC 6455 §5.5 has always required and which a generous limit would otherwise let through; and the HTTP body reader reserves at most `HTTP_BODY_RESERVE_MAX` against a `Content-Length`, so a caller that set no `max_body_bytes` -- an outbound client, or a test -- no longer hands a stranger's header straight to the allocator. The body still grows to whatever genuinely arrives. The other length-prefixed readers in the crate were checked and already bound what they read: IMAP literals against `IMAP_MAX_LITERAL` server-side and `MAX_LITERAL_BYTES` client-side, SSDP into a fixed datagram buffer, SMTP lines against `SMTP_MAX_LINE`. Steel's ws relay copies bytes without decoding frames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YzxdhSaiMcStfA59dmvQW

60 days agor1870400018:20801replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

35 operations, since the previous mark · compare with the head
+Merge branch 'agent/shieldproto': Shield carries an application's bytes, and answers them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

60 days agor1870400018:20765replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+The crate's own example compiles.

`fe2o3_syntax`'s front-page example named a constructor that does not exist, used a variable it never made, and reached for the `?` operator this codebase does not use. It has never compiled, which is why `cargo test -p oxedyne_fe2o3_syntax` has been red for as long as the doctest has run. Replaced with the shape the crate actually has: configuration structs, `res!`, and a message read by matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

60 days agor1870400018:20764replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+A dialling peer finds out which address its packets leave by.

The proof of work on every packet is bound to both ends' addresses, so both ends have to agree on what those are. Two ways they could disagree, and both would have shown up as every packet failing validation for no visible reason: - A client socket bound to the unspecified address reports that address as its own, while the receiver sees whichever interface the routing table chose. `Client::source_ip` asks the same routing table the same question, using a throwaway socket connected to the same destination, and uses the answer. - A server bound to the wildcard address is not told which of the machine's addresses a datagram arrived at, and so cannot reconstruct what the sender bound its proof to. `Server::bind` now refuses that address and says why, which is a better way to find out than every packet being dropped. `NoDatabase` joins `fe2o3_iop_db`, for the callers -- a Shield server among them -- that are parameterised by a `Database` because they *may* hold one and are perfectly usable without. Naming a real implementation in order to pass `None` meant taking a dependency on one. Every operation refuses rather than answering emptily, so a caller reaching for a database it does not have is told at the call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

60 days agor1870400018:20758replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+An application payload, an answer to it, and a peer that dials.

The crate could send a signed, proof-of-worked, chunked packet and its server could receive, validate and reassemble one, and then nothing happened: no message type carried a caller's bytes, no handler could answer, and the only `recv_from` in the crate was the server's own loop, so a dialling peer had nowhere to hear a reply. A library user could not carry a byte. Added, all of it generic: - Two message types (1,024 and 1,025) and their syntax commands, carrying an opaque payload the protocol chunks, proofs, signs and reassembles without reading. Types at or above 2,048 are the library user's own. - `Answer`, which a handler returns: nothing, or bytes that go back under the message identifier the question arrived with, to the address it arrived from. Correlation is the packet header's message identifier, so the answer needs no second connection -- which is what makes it reach a peer behind a router. `ShieldCommand::build` therefore takes the identifier rather than drawing one, because a caller that could not say which identifier its question went out under could not recognise the reply. - `Client`: bind, `tell`, `hear` with a timeout, `ask`. It validates what arrives with the same `Protocol` a server does, and drops anything that is not the answer it is waiting for. - `Protocol::accept`, which is the packet path both halves share -- guard, validate, assemble -- with dispatch left to the caller, because a server answers requests and a client hears answers. - `Server::bind`, separate from `run`, so a caller can learn where the server landed before the loop that never returns starts. The loop is now on tokio's socket rather than a blocking one polled from inside an async function. Four things were wrong on the way, and are fixed here: - `server_address` did nothing: the server took the machine's network address whatever the configuration said, so loopback could not be asked for and two peers could not be run on one machine. An empty setting, or the word `local`, still means the machine's own. - A packet that was not part of the handshake sequence skipped the address guard entirely -- so no rate limit applied to it, and, since the guard is what creates the address log the difficulty is read from, such a packet could not be validated at all. Every packet goes through the rate limiter now; only the sequence check is skipped, because there is no sequence to be out of. - A packet carrying the key it was signed with, outside the opening handshake request, had its signature reported as *unchecked* rather than failed, so it was accepted on its proof of work alone -- by anybody willing to spend the work. It is now refused, except for the two application types, which travel outside any session and so have no other way of carrying a key. - The difficulty curve narrowed the measured request rate to a `u16` before multiplying by it. A burst measured inside one millisecond reports a rate of `u64::MAX`, which became an arbitrary number and then overflowed on the way to demanding more zero bits than the hash has. It saturates at the configured maximum instead. `tests/wire.rs` runs two peers on one machine over the real wire: a payload of several chunks round-tripping, two exchanges at once each finding its own answer, an answer under an identifier nobody asked under being refused, a well-formed packet with one byte altered after signing being dropped while the same packet unaltered is answered, and rubbish of six shapes not stopping the loop. The two examples in the crate's own documentation, which named an API that has never existed and had never compiled, now describe the one that does. The commented-out HResp1 machinery is left commented. Nothing here pretends the handshake exists: no session is established and nothing is encrypted, so a deployment must fix its difficulty -- `server_pow_zbits_min` equal to `server_pow_zbits_max` -- because a difficulty that rises with the request rate would silently stop a peer that has no way of being told about the rise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

60 days agor1870400018:20750replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

111 operations, since the previous mark · compare with the head
+An argument has one name, whichever of its names you write.

A syntax gives every argument a canonical name and one or two hyphenated aliases. A message read off the wire was filed under the canonical one and a message built by hand under whatever the caller happened to type, so the same argument had two names depending on which way it was travelling. A required argument added as `-zb` failed validation for being absent, and one that arrived as `PowZeroBits` could not be read back as `-zb`. Both halves of every message the SHIELD protocol builds went through that, which is why none of them had ever been sent. Names now resolve to the canonical one on the way in and on the way out, in `Msg` and in `MsgCmd`. An argument the syntax does not know keeps the name it was given, so a lookup of something absent still answers `None`. A message printing itself said its arguments by the name it files them under, which is not one a reader could type back, and said each command's name twice. It now prints the short hyphenated form, so `from_str` can read what `to_string` wrote -- which the crate's own test suite has been asserting, without running, since the error macros changed under it. That test file is repaired here: seven `err!` calls in the pre-`;` form, which is what stopped it compiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

60 days agor1870400018:20638replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

53 operations, since the previous mark · compare with the head
+Run the two loop filters, and the picture is exact with them on

Deblocking and the sample adaptive offset. Both are IN the loop -- the encoder ran them too -- so a decoder that skips them is not showing a rougher picture, it is showing a different one. Sixty photographs across three years now decode identically to ffmpeg with both filters running at both ends, luma and chroma, no difference anywhere. The tests no longer pass -skip_loop_filter; HEVC_NO_FILTERS turns them off at both ends, which is how a fault in the codec proper is separated from one in the filters over it. The deblocking filter is short here because every coding unit in a still picture is intra, so every boundary that is filtered at all is filtered at full strength -- no motion vectors or reference indices to weigh. What it still has to get right is when NOT to filter, and that is what the tests are about: a step of a hundred and forty levels across a boundary is something in the photograph and must survive, while a step of four is an artefact of coding and must go. Both are put at the same place with the same settings, so nothing but the size of the step can account for the difference. **The second threshold table was shifted by one place from index twenty-six on**, and what it produced was a filter that smeared a real edge -- caught only because that edge was in a test on purpose. So both tables are now parsed out of the specification and compared entry by entry, the same discipline the context initialisation values and the transform matrix are held to. The offsets read the picture the deblocking filter left and write somewhere else. A sample that has already been offset must not be what its neighbour is compared against, or the offsets walk across the picture.

60 days agor1870400018:20584replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Put the grid fixture the right way round

The fixture wrote columns before rows, to match a reader that read them that way, and the two wrongs made a passing test. What settles it is a real photograph: 3,088 samples wide out of 512-sample tiles needs seven across, and only one reading of the box gives seven.

60 days agor1870400018:20568replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Assemble the tiles, and turn a photograph into something to look at

The whole way from a HEIC file to a picture: the container's boxes, every tile through the decoder, the grid assembled, the photograph cropped out of its top left, and colour difference turned into red, green and blue. **A bug in the container reader that nothing had exercised**: the grid box gives rows before columns and they were read the other way round. A photograph 3,088 wide out of 512-sample tiles needs seven across and five down; read swapped it came out five across and seven down -- which counts to the same thirty-five tiles, so the existing check that a grid names as many tiles as rows times columns passed all along. Nothing found it until something assembled a grid. The assembly is checked tile by tile against ffmpeg's decode of each tile -- ffmpeg exposes each one as a stream of its own -- so a grid built transposed or one tile out fails. Seventy tiles, worst sample difference nought. The colour conversion is held to three things a hand-written one usually gets wrong: the studio range must reach real black and real white, a picture with no colour difference must come out grey at every level and in both matrices, and Cr must carry red rather than blue. The half-size colour planes are stretched by bilinear interpolation between sample centres, which for 4:2:0 means the chroma sample sits a quarter of a pixel up and to the left of the luma one. heif-convert cannot stand in as a whole-image oracle on this machine: libheif is installed but carries no HEVC plugin, so it reads the container and then decodes nothing. libheif-plugin-libde265 would fix that.

60 days agor1870400018:20566replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Read a picture's own scaling lists rather than refusing it

Two in five of the photographs sampled carry lists of their own, so refusing them refused two in five of the library. They are now read: coded outright as a chain of differences, taken from an earlier list in the same set, or -- where a list names itself as its source -- taken from the default, which is the one case where "predicted from" does not mean "copied from". The corner value of the two largest sizes is coded on its own and kept on its own, and the sixteen and thirty-two sample matrices are the eight-sample one with each value covering two or four samples each way. Twenty-eight of forty photographs now decode, every one of them sample for sample against ffmpeg. The other twelve are JPEGs under a .heic extension and never reach the decoder at all.

60 days agor1870400018:20555replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Decode a coded picture, sample for sample against another decoder

The syntax walk and the reconstruction, interleaved because they have to be: a block is predicted from its neighbours, so it cannot be predicted until the ones before it in coding order have been RECONSTRUCTED and not merely parsed. Twenty-four real HEIC photographs decode identically to ffmpeg's own decoder, luma and chroma, 262,144 samples each, no difference anywhere. The remaining sixteen of forty carry scaling lists of their own and are refused by name rather than decoded wrongly. Four faults found by that comparison, none of which any self-consistent check could have found: - The arithmetic decoder's OFFSET WAS UNSCALED at startup. This decoder keeps the next bits pre-read, so the offset must be shifted up by however many are in hand; putting the raw nine-bit value there left every comparison too small by a factor of 128 and the first hundred bins all came back as the more probable symbol. The interval-invariant test passes either way, which is exactly why it could not catch this. - Availability was "has this been reconstructed" rather than "is this earlier in decoding order". The four prediction blocks of one coding unit have their modes read before any of them is reconstructed, and each draws its candidates from the one before -- so every such block took the flat mode as its candidate and picked the wrong mode out of the list, with no change to the bin count. - A coding unit that carried a change of quantisation parameter also wrote a depth of nought over its own, and the next block's split flag was then read against the wrong context. - **The desync that took longest**: the quantiser delta is coded at the first transform unit with ANY residual, and a four-sample quad shares its parent's chroma flags -- so the first of four, with no luma residual of its own, is where it belongs. Reading it at the last of the four instead put those bins at the wrong point in the stream, and the picture from there on was decoded from the wrong bits. Two other real ones: the sao_offset_abs bound was 127 where the bit depth makes it 7, and entry point offsets count emulation prevention bytes (§7.4.7.1), so the cut into rows is made in the escaped data and each row unescaped after. The row-length check that localised the last of these stays: the encoder said how long each row of blocks is and the decoder has just read it, so the two must agree, and when they do not the error names the row.

60 days agor1870400018:20532replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

31 operations, since the previous mark · compare with the head
+Predict a block from the samples already decoded around it

Thirty-five ways: planar, flat, and thirty-three directions, plus the three things that happen before a sample is predicted and each of which changes the answer -- substituting for neighbours that are not there, smoothing the boundary where the size and the direction call for it, and nudging the first row or column of the flattest modes towards what they abut. Every one of those carries a "not for chroma" or "not at thirty-two" or "not at four", and getting one wrong gives a picture that is ALMOST right, which then predicts the next block and the next. So each exception is a check of its own. The properties asserted are ones the implementation cannot fake. A uniform neighbourhood predicts a uniform block in all thirty-five modes at all four sizes, which catches interpolation weights that do not sum to thirty-two and a reference array read a sample out of step. The two forty-five degree directions shift the boundary by exactly one sample a row, so their answer can be written down without arithmetic. Planar is symmetric under swapping the axes. And planar does NOT reproduce an arbitrary plane -- the right and bottom edges are single samples from past the end of the two boundaries -- so the test that claimed it did was wrong about the codec rather than finding a fault, and says so where the next reader will look.

60 days agor1870400018:20500replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Turn coded coefficients back into a residual

The scaling that undoes the quantiser and the inverse transforms that undo the transform, which between them are the arithmetic every block goes through. Both published matrices are printed TRANSPOSED against the way the equation subscripts them -- a printed row is the second subscript and a printed column the first. A decoder that reads them the other way round still produces a picture, a wrong one, in a way no amount of staring at the table reveals. So the four-point inverses are written out in the test as the arithmetic anybody who has implemented one knows by heart, and that is what settles the orientation. Three more checks the decoder's own arithmetic cannot fake: every size of the cosine matrix is orthogonal to within the measured tolerance of the integer approximation (one part in 550 at sixteen points, the worst of the four); a block whose only coefficient is the direct one comes back flat at every size, which is what a stride worked out wrongly puts ripples into; and the six quantiser factors stand in the ratio of the sixth root of two, checked against the arithmetic they were built from rather than against anything here. The matrix itself -- 1,024 numbers -- is parsed out of the specification and compared entry by entry, the same discipline the context tables are held to. The last shift is the caller's, not the transform's: a skipped block takes a shift where the transform would have been and then the same one, which is what keeps the two paths in the same units.

60 days agor1870400018:20496replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Hold the context variables an intra picture codes its syntax against

The arithmetic decoder could answer a bin against a context; what it had no way to say was which context. This is the other half: the eighteen sets of context variables an intra still picture draws on, their initialisation values for an intra slice, and the rule that carries them from one row of blocks to the next. They sit in one flat array with a base per set rather than a struct of named arrays, because every photograph in the corpus is coded in wavefronts and the whole lot is copied at the start of every row. Rows holds that rule: a row begins from the state saved after the SECOND block of the row above, and only the first row begins fresh. A decoder that treats a row boundary as a fresh start decodes plausible rubbish from the second row onward, which is why the rule has a test of its own before anything can exercise it. An index past the end of its set is refused rather than clamped. Reading the wrong context produces a picture rather than an error, and a picture that is subtly wrong is the hardest kind of fault to find. Two hundred and thirty numbers copied out of a document by hand, every one of which silently ruins a picture if it is wrong, so they are checked against the document rather than against the decoder that uses them: the test parses the published tables out of a text rendering of Rec. ITU-T H.265 and compares every entry, including the ranges Table 9-4 says an intra slice takes -- cbf_cb/cbf_cr at 0..3 and 12, transform_skip_flag at 0 and 3, sig_coeff_flag at 0..41 and then 126..127 with nothing between. Proven by putting one digit wrong in the largest table and watching it fail. Set HEVC_SPEC_TEXT to run it; it says so when absent rather than passing quietly.

60 days agor1870400018:20487replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Decode the bins an HEVC picture is coded in

The CABAC arithmetic decoder: the three ways a bin is asked for -- against a context that adapts, bypassed at even odds, and the terminating bin that says where a slice or a row of it ends -- with the probability tables and the state transitions from H.265 Table 9-46 and 9-47. Two properties are asserted, since this is the last piece that can be checked before a picture comes out. Every context starts in a state the tables have: all 256 initialisation values against every quantisation parameter a slice may carry, including the negative ones a high-bit-depth picture allows. And the coding interval stays between 256 and 510 after every bin, over twenty thousand bins of arbitrary input -- a renormalisation one shift short satisfies neither and decodes plausible rubbish rather than failing.

61 days agor1870400018:20484replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Read where each row of an HEVC picture begins

The slice segment header, and with it a fact that changes the shape of the decoder still to be written: every one of the 359 HEIC photographs measured is coded in wavefronts. The arithmetic decoder is reset at the start of every row of coding tree blocks, from the state saved after the second block of the row above, and the header carries a byte offset for each row. The first reading of this header treated the flag as something rare and refused all 359 files. What caught it is now an invariant the header enforces: under wavefront coding there is one piece per row, so the number of pieces the header names must equal the number of rows the sequence parameter set implies -- sixteen for a 512-pixel tile at 32, twenty-three for the 720-pixel picture. Those two numbers come out of different NAL units, so a header read one bit out of step cannot satisfy both. All 359 now read, none refused. The picture parameter set keeps four more fields for the same reason: the count of reserved header bits, whether an output flag is present, whether the deblocking settings may be overridden, and whether the loop filter crosses slices. Each decides whether a field exists in the slice header, and guessing any of them puts every field after it one place out.

61 days agor1870400018:20478replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+Read what an HEVC picture says it is, before decoding one

fe2o3_graphics::hevc begins the decoder the HEIC work needs: NAL units out of a length-prefixed stream or an Annex B one, the emulation prevention undone, the hvcC record unpacked, and the sequence and picture parameter sets read. Nothing is decoded yet, and the parameter sets are the part that can be checked before a pixel exists: the size a sequence parameter set codes has to agree with the size the container's ispe property declares, and an encoder writes those two numbers from different places. Read against 359 real HEIC photographs: 359 parameter sets read, none refused, every size agreeing. The same pass says what the paths still to be written have to handle first. The corpus is uniform -- 8-bit 4:2:0, coding tree blocks of 32, the sample adaptive offset on, no PCM, no scaling lists, one tile -- and the tiles are 512 square in 350 files, 1024 square in eight, with one ungridded picture at 720. Anything else is refused by name rather than decoded approximately.

61 days agor1870400018:20464replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Read a HEIC's boxes, so the picture in it can be found

fe2o3_graphics::heif reads the half of HEIC that is not a codec: which item is the photograph, where its bytes are, the grid of tiles it is cut into, the decoder configuration those tiles are read with, and the Exif block the camera wrote. It decodes nothing, and it is written before any HEVC decoder because the two things a photograph library wants first are in the container rather than in the coded picture. The size is the reason. A reader that takes the first ispe box it meets gets the thumbnail's extent, since a phone writes the thumbnail's properties first; and where the photograph is a grid -- which every recent iPhone writes -- no ispe in the file carries the assembled size at all, only the grid item does. Both are resolved here by reading pitm and ipma. Measured over 359 real photographs against exiftool: no disagreement. The same 359 put through the first-ispe rule: 358 wrong. size() applies irot and extent() does not, and the difference is documented where both are: a phone writes irot and an Exif orientation that say the same thing, and a caller applying both lays a portrait photograph out landscape. Refused rather than guessed at: boxes that do not tile their parent, a grid naming the wrong number of tiles, an item whose extents fall outside the file, and a file that does not open with ftyp -- which is a fifth of the .heic files in one real library, being JPEG under another name.

61 days agor1870400018:20460replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Merge branch 'ore-review': the render answers where content ended up.61 days agor1870400018:20455replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Ask the render where content ended up, since a note is not the only asker

The reverse lookup a note resolves through -- content in, file and spans out -- was a local of `render::notes`, and a flag names content too: its reader wants a line in a file rather than an offset into an operation. Lifted to `Placement`, public, built once and asked repeatedly, with `Repo::placement` as the way in. Content that renders nowhere is answered with no place, which is what lets a caller say so rather than invent a location. `NotePlace` is `Place`: the struct was always "where content renders in one file", and notes were merely its first caller. `notes` is now the lookup plus the note text and nothing else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

61 days agor1870400018:20454replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+Merge branch 'ore-overlap': an overlapping edit is arbitrated, not interleaved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

61 days agor1870400018:20434replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Arbitrate an overlapping edit, since half a function each is nobody's

Two people who rewrite one function body at once have their surviving base fragments interleaved with two insertions, in anchor and op order. That is convergent, conserved and attributed, and it compiles for nobody: the trial that found it called it the worst of the four things it found, and it is the last of them still open. The renderer arbitrates instead. Concurrent splices whose named content intersects form a graph -- one edge per pair the Overlap flag names -- and its connected components are the arbitration groups. Components contended by the same set of replicas over the same files are taken together, which is what stops two components of one file being won by different people. The member highest in op order prevails, and every member concurrent with it yields: its removals do not bury, and its own insertion is buried whole, so the region holds whole hunks and never an interleave. The slot is still placed, because a slot that has lost its claim shows nothing and stays as an anchor target, so anything anchored into yielded content still resolves. This is the shape the cross-file cycle already takes, and the shape §5.6 took before it. Two completions, both earned by a planted sweep of four thousand trials rather than argued for. A member in the winner's causal past does not yield, or a capture that emitted two hunks would void the winner's own first one. And yielding is transitive: a splice anchored wholly inside a buried insertion is buried too, or it renders as a fragment at a dead site with no flag firing for it, which is the same scramble one round later and smaller. Three things the sweep found that the rule as first stated did not say. The decision is the group's and not a pair's. Three authors in a chain put the first and the third in one group although they never named a common byte, so a flag saying "this operation rewrote your region" is false of roughly three yields in ten. Flag::Yielded therefore carries the group and its maximum, and a transitive yielder carries the buried insertion it sits inside, which closes the question of whether that case wanted a second flag code. The region is not a promise of one author. Where a third party has synced with one side of a collision and not the other it joins the group as the maximum, the exemption keeps the side it saw, and the region composes two authors' whole hunks. The exemption is load-bearing, so this is a consequence and not a defect; what is promised is whole hunks. Yielding is not only subtractive, and the merge is not monotone. Where the loser deleted text the winner did not, that text comes back and the render is larger than it was; and merging two components changes a winner, so an edit separate components would have buried can be revived by the exemption. Both are expected, both have a case. Flag::Overlap stays, in the relation Demoted has to Confined. It is the only flag that says which two operations actually met, and it fires over a move as well as a splice, where the arbitration is deliberately confined to splices. The wire is additive: one flag code, no ORESNP bump, no operation body, no encoding change, and frozen snapshot bytes carry no flag of a kind that did not exist when they were written. The overlap decision folds into the cross-file cycle's fixed point rather than beside it, since yielding changes the tombstones and the tombstones are what a trial layout shows the cycle rule. Thirteen cases transcribed from the sweep, every expectation that sweep's own render, each under every delivery order with conservation checked on all of them; and a planted sweep that asserts the rule fires at all and leaves no fragment at a dead site. Both completions were switched off in turn to confirm the cases catch their absence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

61 days agor1870400018:20433replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

46 operations, since the previous mark · compare with the head
+Record the descending sense the successor re-run gives concurrent blocks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

62 days agor1870400018:20386replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Merge branch 'ore-gitexport': the fast-import stream is emitted as well as read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

62 days agor1870400018:20384replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Write the stream back out, since the interface git keeps runs both ways

The parser reads git's fast-import stream because that is the one interface git maintains for foreign consumers of a repository. The same sentence holds for a producer, so an emitter joins it: a history spelled as this stream becomes a git repository without this crate ever learning what a packfile is. It shares the parser's vocabulary rather than declaring a second one, so a stream parsed and re-emitted is expressible in the type system, and it keeps the parser's posture of refusing what it cannot say. That posture earns its keep here. Handed a path that is both a file and a directory, git takes the stream, keeps the directory and drops the file without a word -- measured against git 2.53.0, not assumed -- so the emitter refuses that tree by name before a byte goes out. A path holding .git aborts git part way through with a crash report and a half-built repository, so it is refused too, along with an absolute path, an empty component, a trailing separator and a NUL byte. Nothing here spawns a process or reads a clock. Emitting bytes is pure, the crate still compiles for wasm32-unknown-unknown, and running git over what comes out is the caller's business. The tests run both ways: the unit tests read the emitted stream back through the parser, which proves the two halves agree and no more, and a gated test pipes it into a real git fast-import and compares the checkout byte for byte and mode for mode. One of those measures git's own silent drop, so the refusal is answering something real. Another pins the property a mirror will be checked against: a tree object is a hash of content alone, so the same content gives the same tree name under a different message and date, while the commit name does not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

62 days agor1870400018:20383replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Merge branch 'ore-relay': the engine picks the sync mode both peers ask for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

62 days agor1870400018:20375replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Let the engine choose the sync mode, since two peers now ask it

The rule for sizing a sketch -- the two logs' difference in length plus a fanout for every head either frontier carries, and the walk where that guess reaches the smaller log -- lived in one caller. A second peer that runs the same session now needs the same answer, and a rule copied is a rule that drifts. A guess of nothing means two empty logs, and those get the walk rather than a sketch of nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PR2Vb7FdgfMiVLMg5pL6Xb

62 days agor1870400018:20374replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Merge branch 'ore-filemode': the file mode joins the operation vocabulary, and both durable formats say so.62 days agor1870400018:20368replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Say which of git's five modes the vocabulary can hold

The parser has always read all five. Three of them name a file with bytes in it and now have somewhere to go; a gitlink and a directory entry name something that is not this repository's file at all, and there is nothing for a FileMode to say about either. So the answer is an Option and not an error. What to do about a submodule -- refuse, or leave the entry out -- is a decision, and the consumer is the one holding the path and the commit the message would have to name, so the consumer makes it rather than being handed a guess.

62 days agor1870400018:20367replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Raise the segment version for the vocabulary it now carries, and keep the old one readable

ORESEG goes to 3. Not a byte of the framing moved: what the version declares is which operations the records inside may be, and there is one more of them than there was. Version 2 stays readable, which the v1 to v2 bump could not offer. Version 3 is a strict superset -- same framing, one code added on top -- so every segment ever written under 2 means under 3 exactly what it meant under 2, and no repository owes a migration for this. What the bump buys is the better refusal: a reader meeting a version it does not know says which version it met, rather than reporting an operation code it cannot place, and that is worth the one byte it costs. A writer continuing somebody else's segment now keeps that promise rather than merely intending it. Appending a FileMode to a version 2 segment would leave a file whose header offers a vocabulary its records exceed, so the writer refuses and names both the operation and the version; a caller with such a record to write starts a segment at the current version, nothing having ever said that a log's segments share one. The frozen bytes are re-pinned. The one-record fixture moved a single byte, the version, since it carries a mark and a mark's encoding has never changed. A second fixture pins the FileMode itself, so the operation the bump exists for has its spelling on the record too.

62 days agor1870400018:20363replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

26 operations, since the previous mark · compare with the head
+Let a file say what it is, and not only where it is

An imported script that cannot be run is not the script that was imported, and the executable bit was the one thing a whole history still lost. So the vocabulary gains Op::FileMode at wire code 8: it names a file by identity and asserts its mode, exactly as a rename names a file by identity and asserts its path, so the assertion survives every rename and every edit the bytes go on to have. The mode is three values and not a number -- normal, executable, symlink -- and normal is the default, which is what makes the operation additive: a file no FileMode ever named is a normal file, so every history written before today means today what it meant yesterday. A symlink is then git's own model verbatim, a file whose bytes are the target. Two written concurrently settle where two concurrent renames settle, by operation order, and that comes for nothing: the render reads the lifecycle operations in op order already. The file listing every convergence case is checked against now shows a mode where it is not the default, so all of them prove the mode is a function of the operation set rather than of the delivery order. ORESNP goes to 4 to carry it. A snapshot exists so that a checkout need not replay the log, and a checkout that could not tell a script from a text file would write out a tree the history does not describe. Old snapshots are refused rather than misread, which costs nothing: nothing is lost by discarding a snapshot.

62 days agor1870400018:20336replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

93 operations, since the previous mark · compare with the head
+Merge branch 'agent/wsproxy': websocket routes for Steel, and the primitives the ceremony gateway needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20242replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Give the server test's vhost the field the config gained

VhostConfig and VhostRuntime are built by hand in tests/server.rs, so a new field is a compile error there rather than a default. It has none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20241replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Percent-encoding where more than one caller can reach it

data_url.rs held a percent decoder as a private function, so anything else needing one -- a query parameter, a name carried in a token a browser wrote -- had to write its own. http::pct decodes bytes, decodes text, and encodes a component escaping exactly what encodeURIComponent escapes. That match is the point: a value a browser wrote and a peer reads must survive the trip byte for byte, and the browser's rule is not ours to choose. The expected strings in the test are what a browser console prints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20238replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Give one path to a websocket server of its own

A site whose pages, files and API stay with Steel had no way to hand a single endpoint to a separate process. The only forwarding on offer was a proxy_route, which claims a path prefix and everything under it, so a gateway at /ws meant either moving the whole site behind the proxy or teaching Steel the gateway's protocol. A ws_route names one exact path and a ws:// upstream. On an upgrade to that path Steel forwards the handshake, relays whatever the upstream answers -- a 101 or a refusal, verbatim -- and then copies bytes both ways until an end closes. It never parses a frame, so the two ends are free to agree on a sub-protocol, extensions and message sizes without this hop being told. A request to the same path that is not an upgrade falls through untouched. The relay itself moves to srv::wsproxy, which handle_proxy_websocket now calls: the two kinds of route differ in how they match a request, not in what forwarding a handshake means. ws_routes is checked first, an exact path being more specific than a prefix that contains it. wss:// is refused with an explanation rather than spoken as plaintext. This forwards to a server on the same machine; an operator who needs TLS to the upstream is not describing loopback. The upstream in the tests is fe2o3_net's own websocket machinery rather than a stub, so the accept value the client checks is one the upstream computed from the key the client chose -- which is what a browser will do. Dropping the Sec-WebSocket-Key from the forwarded headers fails two of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20232replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Write the JSON bytes a signature can be checked against

Dat::json puts a space after every colon and comma. That is easy to read and it is not what a browser's JSON.stringify writes, so a peer verifying a signature a browser made over a JSON object could not reconstruct the bytes that were signed. Dat::json_canonical follows RFC 8785: no whitespace outside strings, object members ordered by their keys' UTF-16 code units, and the escapes the scheme fixes. It refuses what it cannot promise -- a float, whose canonical form defers to ECMAScript's number serialisation; an integer past 2^53 - 1, which a JavaScript signer cannot hold and so cannot have signed; bytes and the rest of the daticle catalogue, each named in the error. The ordering test carries the members of the RFC's own example, whose point is that the order is neither the written one nor a byte-wise sort of the escaped keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20209replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Sign in the P-256 encodings the verifier already accepts

ecdsa.rs could check what a browser produced and produce nothing itself, so a Rust client of a gateway that authenticates device keys had nowhere to go, and a test presenting a real signature had to reach into ring. P256KeyPair generates or loads a key, hands back the 65-byte uncompressed SEC1 point WebCrypto's exportKey('raw') yields, and signs to the 64-byte r || s form verify_p256_sha256_fixed takes. The PKCS#8 bytes are retained because ring consumes them on load and a key that cannot be written down cannot be reloaded. The loader is checked against openssl: the test key's x and y are the values the DER's own documentation derived with it, not values this crate computed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20205replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Frame a websocket without holding a database

A server that owns its socket -- reads the upgrade request itself, then splits the stream so one task writes what another task's reads provoke -- could not use ws::core at all. WebSocket carries five type parameters describing a database it never touches on the framing path, and the framing lived inside its methods, so the only way to get a frame on the wire was to name an Encrypter, a Hasher and a Database first. The three operations that need none of that are now free functions, and WebSocket's methods are what calls them: accept_response builds the 101 from a request (validating the key rather than digesting whatever arrived), encode_message turns a message into frame bytes given only whether this end masks, and read_message decodes one message from anything that reads. A first frame carrying the continuation opcode used to reach an unimplemented!() -- a panic a stranger could provoke on a server. It is now an error, which is what a peer sending nonsense deserves. The second unmasking pass over an already-copied payload is gone with it; it wrote to a buffer nothing read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5xSadrETfJ1yGtTXJjCZQ

62 days agor1870400018:20197replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Bring the store's integration tests back to the record they test

The file still named PostKind, removed when authorship and categories replaced it, so the whole suite failed to compile and its nine tests -- the only ones that put a post through a real database -- have not run since. No test body changed; the two record literals now carry the fields the record now has. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20187replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Measure the encoder's rig fixture against a file the server sends verbatim

The coding checks added beside the HEAD sweep compared `Content-Length` against `stat` of an HTML file, and the rig runs Steel in development mode, where a document is rewritten on the way out to carry the refresh hook. The file on disk was not the entity being sent, so `stat` was the wrong oracle and two checks failed on behaviour that was right. A stylesheet instead: `text/css` is compressible and is not a document, so what is on disk is what goes out. The whole sweep is green, and the GET's own length is compared to the HEAD's rather than only to the file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20183replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Take a trailing slash to the page it names

`/asides/` answered 404 while `/asides` rendered, so a reader who typed the slash, or followed a link that carried one, was told the blog did not exist. A directory-shaped URL is what most of the web looks like; the distinction was nobody's intent. A 301 to the canonical spelling rather than serving both, so the prose keeps one address -- two URLs for one page split a reader's history, a shared link and a search engine's idea of where the piece lives. Posts get the same treatment, being the same mistake one path deeper. The query is carried across so a chip link still lands narrowed, but only when every character of it is printable ASCII: it came off the request line and it is going into a `Location`, which is the shape a response-splitting attempt takes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20168replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Offer a facet only where a post wears one, categories included

The tag block was suppressed where no post carried a tag and the category block was not, so a blog whose posts carried neither drew the whole configured taxonomy over the same nothing the tags had declined to draw anything over. Every chip in it narrowed the list to zero, which is a row of controls that can only disappoint. A config's categories are a vocabulary the site may file under, not a claim that anything is filed. Where something is filed, the whole vocabulary is still offered: what a reader may narrow to is what the site offers, not only what the posts in front of them happen to wear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20164replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Tell a reader who filtered to nothing that the blog is not empty

One line served both states. A blog with no posts said "Nothing here yet.", and a filter that had excluded every post revealed the same line -- so narrowing too far read as a blog with nothing in it, and the way out of that is to widen the filter, which is the one thing those words do not suggest. Two states, two lines. The server draws the first and decides whether it shows, knowing whether there are posts; the script shows the second, knowing whether a filter left any, and never touches the first. The wording is the PWA's, so the two readers say the same thing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20152replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Assert the no-cache invariant on every surface that holds it, not one

Six surfaces call `cache::generated` and one of them was tested, so a refactor dropping any of the other five would have gone out silently -- and what it costs is an author forcing a refresh to see their own post, which a reader would never think to do. `cache::assert_not_held` puts the invariant in one place, and the tests of the publish index, an empty index, a post, a post that does not exist, the Atom feed, a console page, the two not-yet-an-admin pages, the console's JSON, its JSON error, its CSV export and its unknown-route `404` all say the same thing through it. The two served scripts are checked the other way, since those are held on purpose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20145replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Let no store invent a lifetime for a miss

RFC 9110 15.5.5 makes a `404` heuristically cacheable, which is the one class of answer that must never be held: what is missing now is exactly what somebody is about to add. The concrete symptom was an avatar -- a byline asked for a picture before its owner had uploaded one, the browser kept the miss, and the picture they then chose never appeared for them. Every `404` this handler produces goes through `cache::generated` now: the missing avatar, the two dashboard-not-configured answers, the three static-file misses, and the console's unknown-route fallback. The `500` beside them and the `303` are left alone, being uncacheable already. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20139replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Say the mismatch reason without the source's indentation in it

The reason a comment claiming a known commenter from a new address is held was a literal wrapped across two lines with no continuation, so three tabs of source indentation sat inside the string and the moderation queue read "commented from before". Nothing about the judgement was wrong and nothing tested the words. It is a const now, since a reason is shown to a person and is worth being able to test, and the test gathers every reason the moderator can give: no tab, no newline, no run of spaces, nothing padded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20120replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Date an imported post that says nothing about when it was written

`import_dir` took the date out of the filename and kept `None` where there was no `YYYY-MM-DD-` prefix, so a directory of plainly-named files landed in the store undated -- and the feed, which must say when every entry was updated, dated each of them to the epoch. That files the whole import under 1970 in every reader that takes it, silently. The composer's save has dated an empty field to today since the epoch bug was found. The import writes the same records by another route, so it gets the same fallback: a fix on one path and not the other is the same bug with a different way in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20116replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Ask for the runtime the blocking pool waits on, rather than assume it

`Handle::current` panics where there is no runtime, which is an `unwrap` by another name: the two blocking tasks that read a file and encode a body each called it, and a panic in a pool thread reaches the caller as a `JoinError` saying only that the task did not finish. `try_current` gives an error that names what was missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20112replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Answer a HEAD about the whole file, whatever range it asked for

RFC 9110 14.2 defines range handling for GET alone and requires a server to ignore the field on any other method. A HEAD shares the GET's dispatch branch here, so it was reaching `range::resolve` and being answered 206 with the length of a window -- naming bytes nobody can read and understating the size of the thing being asked about, which is the one fact a HEAD exists to report. `head_only` was already in `loc.data` for the read tally, so it is read once at the top of the branch and both uses take it from there. The rig's HEAD sweep asserted the old behaviour, so it is inverted; while it is open it also gains a page worth encoding and the coding checks that go with it, which prove at the wire what the encoder was just told about a HEAD. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20109replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Encode nothing a HEAD is not going to send

A HEAD of a compressible page read the whole of it off the disk, gzipped it, and then withheld the body at the wire -- paying for the read and the compression of bytes nobody receives. The cost falls on exactly the requests that were meant to be cheap: an uptime monitor asking the size of a large page, once a minute, forever. The rule belongs in `is_encodable`, which is the one authority on whether a response may be coded, rather than at the call site: a message whose body is deliberately withheld has nothing to encode. Of the two answers RFC 9110 9.3.2 allows -- the identity `Content-Length`, or the chosen coding with no length at all -- this takes the first. It is what a GET accepting no coding would be told, it is what anyone asking how big a thing is wants to know, and it needs no way to suppress a `Content-Length` that `write_all` derives from the body by construction. `Vary` is still set, because a store keyed on the URL alone would otherwise hand this answer to the next client along. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P1hR8YmGDyEZ6Wekg8K37

62 days agor1870400018:20101replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Draw the globe as well as the flat world

fe2o3_geom::proj carried only Equal Earth, which draws the whole world at once. A caller wanting a globe a viewer turns needs the other kind: the sphere seen from infinitely far away, with the far hemisphere behind the near one. orthographic() takes the latitude and longitude the viewer is above and returns the point on the disc. orthographic_cos_c() answers which side of the globe a position is on -- one under the viewer, zero on the horizon, negative behind. The two are separate because the formula cannot tell a far position from the near one in front of it, and a caller clipping a coastline wants to interpolate an edge to the horizon rather than be handed a hole. Nineteen test vectors from PROJ 9.7.1's +proj=ortho at two centres, one of them oblique in both coordinates. PROJ answers `*` rather than a coordinate for the far hemisphere, and that refusal is what the cosine is checked against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

63 days agor1870400018:20096replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Merge ore-silent-deletion-flags: flag the work a concurrent deletion leaves invisible63 days agor1870400018:20090replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Flag the work a concurrent deletion leaves invisible

The self-hosting trial's two silent rounds were one hole: an edit made concurrently with a deletion renders nowhere a reader looks, or as a stranded fragment, and the flag vocabulary had no name for either. A reviewer reading clean flags would ship a tree that quietly dropped a teammate's work from sight. Two flags close it, both judged by the parents as Overlap and Torn are. Stranded names a splice whose every anchored neighbour was deleted by a concurrent operation -- which is what a capture-side delete-plus-insert "move" does to a concurrent edit inside the block -- and names the deleter beside it. SplicedIntoDeleted names a splice concurrent with the FileDelete of the file its content landed in, beside that deletion. In both, a deletion causally ordered with the edit is a decision rather than a race and raises nothing; and a move into a deleted file remains MovedIntoDeleted's territory, which is unchanged. The rendered bytes are untouched -- this is reportage, not placement -- and the snapshot carries the new kinds the way it carried Confined and Won when they were added: two more codes in the same self-describing list, under the same format version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTEk5Ts7rqP3BhrM2vbmoj

63 days agor1870400018:20089replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Merge ore-ignore-glob: match git's ignore shapes over byte paths63 days agor1870400018:20071replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Match git's ignore shapes over byte paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTEk5Ts7rqP3BhrM2vbmoj

63 days agor1870400018:20070replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Give the IMAP test server a realistic folder tree

The example served INBOX alone, so a client exercising folder listing had nothing to list. It now creates Sent, Drafts, Archive, Junk and Trash -- the five roles the server's special-use table already recognises -- plus an ordinary nested folder with a space in its name, and seeds mail into three of them, so LIST answers with the shapes a real mailbox produces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aLFQ6EN327KH7HEokRPq2

63 days agor1870400018:20062replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Wrap a stream we cannot encode in a file that plays it

An MP4 writer: ftyp, moov with one video track's whole sample table, and mdat, around samples and an avcC record the caller's encoder produced. The moov goes first, so a reader has the index before the media, which costs a second pass over the box tree because the chunk offsets are absolute. It refuses rather than emitting a file that is well formed and unplayable: no samples, a zero timescale or duration, a truncated configuration record, a first sample that is not a sync sample, dimensions that disagree with the sequence parameter set, and a sample whose bytes are not tiled by the NAL lengths the record declares. The samples the oracle test uses are FFmpeg's. It demuxes an elementary stream, re-muxes it here, and requires the decode of the result to be identical byte for byte to the decode of the source; FFprobe, ExifTool, GStreamer's qtdemux and a box walker written to the specification each read the file back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:20058replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Shade a shape, and read the shading where the pixel came from

Paint was a flat colour, so a drawing that shades a solid body -- which is most of what makes a rendered figure look like an object rather than a diagram -- could be described in an SVG and not drawn into a pixmap. Gradient is the two cases worth having, linear along an axis and radial from a centre, and Pixmap::fill_gradient is the existing fill under a paint that varies. The gradient is expressed in the path's own coordinates, so a shape and its shading scale and rotate together. Reading it needs the pixel's centre carried back where the shape was defined, which is what Transform::invert is for. A transform that has collapsed the plane onto a line has no inverse, and there the fill takes the last stop's colour rather than failing: the shape is still there to be filled. Sorting the stops rather than believing their order, padding at both ends, and refusing a gradient with no stops or a radius that is not positive, all happen once in prepare and not at a pixel. The sampler and tests written beside it would agree with each other about a position measured across the axis rather than along it, or a radial position taken as a squared distance. So five fixtures in tests/gradient/ are rendered by Chromium and compared premultiplied, because the browser's own buffer was: worst channel one level, mean a tenth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:20051replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+Show a drawing moving, in a file a still reader still reads

A frame sequence had nowhere to go: the codec wrote one picture, and a compile target that walks a state chain produces hundreds. Animation takes them one at a time and writes an APNG, storing only the rectangle in which each frame differs from the last -- so a figure crossing a still background costs the figure. Five seconds of a 960 by 540 explainer comes to 1.2 MB, which is 44 per cent of the same frames written loose. The file is a PNG first. Its default image is its first frame, so a reader that knows nothing of the animation chunks shows that frame and reports no error, which is the whole point of how the format is laid out. encode's filtering and deflating is now deflate_region, since a frame is a rectangle of a pixmap and a still is the rectangle that happens to be all of it. The unit tests check the chunk order, the sequence numbers and the difference rectangle, all of which the encoder and its tests could agree about while the file failed to animate. So FFmpeg and Pillow decode it in tests/apng_oracle.rs, and the pixels compared are the ones the frames were filled with before anything ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:20018replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Ask an IMAP server for its special-use roles when it offers them

LIST now appends RETURN (SPECIAL-USE) when the server advertises the capability, and MailboxInfo::special_use() maps the RFC 6154 attributes to a SpecialUse enum. The point is localisation: a client cannot recognise the Sent folder of a German Gmail account by its name ("[Gmail]/Gesendet"), but the \Sent attribute spells the role the same everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aLFQ6EN327KH7HEokRPq2

64 days agor1870400018:20005replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Call the budget what it now bounds

MAX_LINE_SCRIPT caps the Myers pass on both routes, and on one of them the pieces are not lines. MAX_PIECE_SCRIPT, which is what the Myers pass has always been counting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:20001replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Say what the note resolver can fail at, which is nothing

It returned an Outcome and never used it. A signature that claims a failure mode it does not have costs every caller a res! and tells a reader to look for an error path that is not there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19989replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Cut a binary where its content changes, not where its lines are not

The line pass has nothing to work with in a file with no newlines in it, and what it fell back to was one splice covering everything between the shared prefix and the shared suffix. Three scattered edits in a four megabyte payload therefore re-stored nearly the whole four megabytes, which is what the feature table promised they would not. There is now a second way of cutting the input into pieces. A FastCDC-style gear hash, from a seed pinned in this module, declares a boundary where the content says to rather than where an offset falls, so an edit perturbs only the chunks around it and everything beyond re-synchronises. Sizes are 2/8/64 KiB, well below what a storage layer would chunk at, because a diff wants to localise an edit to a few kilobytes. Above that, the two passes are one function: a piece is a line or a chunk, the numbering and Myers and the byte level refinement beneath them do not know which. The route is chosen from the bytes -- lines where the newlines are frequent enough to cut on, chunks where they are not and the input is long enough to be worth it, and the trimmed splice where both give up -- and diff_routed hands it back so a caller need not infer it. The four megabyte case with three single-byte edits now costs three splices of one byte each, six bytes in all, against 3.9 MB before. That number is frozen in the test, along with the apply oracle over randomised binary pairs with scattered insertions, deletions and moves in them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19983replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Say something about content, and let it follow the content

A note names bytes rather than a line, so the anchoring machinery already in place carries it: it narrows when the content is edited around, travels when the content is moved, and crosses a file boundary when the content does. Nothing was written to make any of that happen. Op::Note is wire code 7, carrying the content it is about and its text as bytes. It must name at least one byte -- a note about nothing is a Mark with extra spelling -- and that is checked on the way off the wire as well as on the way in, for the reason the placement rule is. It claims nothing, so it stays out of Op::regions and out of the overlap flag; Op::note_on is the other reading, and the render is where the two are read together. The render resolves a note into spans of rendered bytes, by reading the provenance the walk already produced backwards. A file gets the notes whose content renders in it, in the order a margin would draw them; the repository gets every note once, with the files it reaches, and says so where it reaches none. Both are functions of the operation set, which the convergence harness now asserts in every case it already ran. A snapshot carries the resolved notes beside the flags, so a frontend reading one can draw the margins without the log, and ORESNP is version 3 with its golden bytes re-frozen around them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19960replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

113 operations, since the previous mark · compare with the head
+Merge branch 'ore/cycle'64 days agor1870400018:19846replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Plant the cycles rather than wait for one, and check the bytes stayed home

Three additions to the file cases. A cycle with a single move in it, which winner-takes-all cannot arbitrate and which is confined instead; the same case shows the classifier's known false positive, a move judged to cross a boundary because the block it carries is made of bytes born in two files. An in-file cycle in a repository that has other files to escape into, which must still be demoted and must not be confined. And a sweep that plants cycles at every length from two to four, half of them with an edit inside a cycling block. The sweep checks the property the whole rule rests on, which is not the same claim as convergence: every byte a confined move named still renders in the file its flag says it stayed in. Two replicas agreeing proves nothing here, since they would agree just as readily on the bytes having gone somewhere neither author named. Run against a register that ignores the confined set, the check fails, which is the only evidence that it is checking anything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19845replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Arbitrate a cycle that crosses a file boundary instead of collapsing it

Two agents each moving one file's contents into the other made a cycle in the anchor graph, and the demotion rule broke it by landing the demoted move in the other file, whereupon the move that followed its anchor landed there too and the file the first move left was emptied. Nothing was lost and every replica agreed; it was still not what either author asked for, and at length three two files emptied. A cycle that crosses a file boundary is now arbitrated as one concurrent group. The member highest in op order completes wholly and every other member is confined -- its claims are not written, so its bytes stay where they were and its slots place nothing -- and both files are told. Cycles inside one file are left to demotion exactly as they were, since a stale position inside one file is annoying rather than wrong and every single-file expectation depends on it. Three things the trial forced and the phrasing does not carry. Whether a cycle crosses a boundary is judged against two layouts and not one: where the bytes would be if the cycle had not happened, and where they were written. Each reading misses cycles the other sees, so the rule is their union, and a cycle between a move and a later move of the same block by the same author is the case that needs the second. A cycle with one move in it cannot be arbitrated, since winner-takes-all would keep its only member and break nothing, so that move is confined. And a member that saw another member is racing nothing and is exempt, but only the causally last such member: a chain of three informed moves would otherwise exempt everything but the head of it. The render becomes a fixed point over the confined set, each pass voiding at least one move and never un-voiding any. It costs one layout for the birth reading and one more per cross-file cycle, which is nothing at all in a history that has none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19842replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

24 operations, since the previous mark · compare with the head
+Tell both authors which of their two moves happened

Two flags. Confined says a move did not happen: here is the file its content is still in, and here is the file it was aimed at. Won says a move took a cross-file cycle outright. The second is derivable from the first and the operation set, and is kept anyway, because the loser's flag reads badly alone. Wire codes 8 and 9, with codecs, round trips and a place in the randomised snapshot sweep. The frozen snapshot bytes carry no flags and are unchanged, so the format version stands at 2: nothing already written decodes differently, and only a snapshot that carries one of the new flags needs a reader that knows them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19817replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Lay a repository out with a move left out of it

Three additions to the placement layer, none of which changes what an existing render does. A claim register can now be built without a named set of moves, so that their bytes stay with whoever owned them before; slots can be placed the same way, so that a move left out places nothing and no anchor can resolve to it. The cut points are still taken from every operation's origins, voided ones included, because division has to be a function of the operation set alone -- two replicas that leave a move out at different moments must still divide their slots identically, or they diverge. The third is a way of asking where the cycles are: the strongly connected components of the anchor graph before anything is demoted, each in op order. Demotion never needed them, since it asks only which slot is blocked, and a blocked slot need not be on a cycle at all. A rule that voids a whole move does need them, and that rule is next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19799replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Compare two magnitudes by what they measure

Equality between magnitudes looked at the significands and nothing else, so 1.234 mega and 1.234 giga -- a thousand apart -- were the same number. Around that sat three more faults on the same four lines: the logarithm was taken of a signed value, which is not a number for anything below zero and cast to an exponent of zero, so negatives were compared raw; a zero took the logarithm of nothing, so no zero equalled itself; and one was subtracted from a u8 figure count that the public fields allow to be zero. A magnitude is now compared by the quantity it denotes. Each side has its prefix folded into its value, and what is compared is the decimal exponent of the leading digit together with the significand rounded to a figure count -- the coarser of the two, so that the answer does not depend on which side of the operator a magnitude sits, which it previously did. Rounding that carries into the next decade carries the exponent with it. Zero equals zero and no other quantity, a sign is a difference, and a value that is not a number equals nothing, itself included. The seven tests that were here pass unchanged, which is worth saying because the reason this was left alone was that they might not: every one of them compared quantities that really are equal -- 1234 mega and 1.234 giga are one number written twice -- so they were pinning the right answer for the wrong reason. Eight tests were added from hand-computed decimal arithmetic, and seven of the eight fail against the implementation they replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19790replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Sign with the base64 the library now owns

The signer reached past the workspace for an encoding the workspace has, so b=, bh= and p= are now written by fe2o3_text's base64 rather than the crate of that name. Nothing on the wire moves: the DNS record and the whole signed message, byte for byte, are what the previous commit produced -- 1054 bytes, same digest -- and a new test takes each tag off a real signing run, hands it to the base64 crate to decode and re-encode, and requires the string back. The RFC 8463 Appendix A vector and the openssl verification still pass, and neither of those would survive an encoder that differed. The dependency stays in Cargo.toml. Eight other modules in this crate name it -- ACME's JOSE wants URL-safe without padding, which is a different encoding, and tls, ws, smtp, imap and the data URL parser want the standard one. They are their own change. The one thing the swap does change is what a malformed input is. The new decoder refuses whitespace, and a b= tag arrives folded across lines, so the unfolding has to happen at the call site. It already did in the one place that reads a tag back; the module header now says why, and the test key is stripped rather than trimmed so that regenerating the fixture without -w0 does not turn into a decoder bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19785replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Put a position on a sphere onto a page

A forward-only map projection module: geodetic degrees in, plane coordinates out, y northward and the scale the caller's, since only the caller knows which way its own axis runs and how wide its page is. Equal Earth is the one projection in it. It is pseudocylindrical and equal-area, so a square kilometre of ground occupies the same area on the page wherever on Earth it is, which is what a map drawn to say *where* has to be honest about. The four polynomial coefficients are the published ones; the eighteen test vectors are PROJ 9.7.1's, which is an implementation outside this library, and agree to one part in ten thousand million. A latitude a hair outside its range clamps and a longitude past the antimeridian wraps, so that a fix arriving from a camera projects rather than answering with a not-a-number. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19778replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Check the answer against somebody else's, and guard the cliff

A codec that agrees with itself proves nothing, and neither does an inference engine. The recorded vectors here came from an independent implementation running the same file on the same input, taken once and frozen, so the repository test compares against an outside answer without that implementation becoming a dependency. Weights are not in the repository, so the tests that want one skip and pass without it. The other guard is not about correctness at all. Whether the accumulator tile lives in registers or spills is decided all at once by the code generator, adjacent tile heights differ by a factor of thirty-two, and a compiler upgrade can move the boundary without a line changing. The companion failure is mul_add reaching the path that has no instruction for it, where it becomes a library call and costs the same. Both produce correct answers thirty times too slowly, which is exactly the kind of thing nobody notices until a scan takes all night, so both are measured and both fail loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19774replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Find a face in a photograph, and say which face it is

Detection is anchor-free over three strides: a score is the geometric mean of two heads, a box is an offset from a cell centre and a logarithmic extent, and five landmarks are offsets from the same cell. Boxes are truncated to whole pixels before they are suppressed, which is what the reference implementation does and matters on small faces, where the truncation decides which of two overlapping boxes survives. Embedding wants those five landmarks on a fixed template, so the crop comes through the least-squares rotation, scale and translation that puts them there -- two by two is small enough to decompose in closed form -- and the answer is normalised, so comparing two faces is a dot product. The two networks disagree about channel order and neither says so: the detector was exported against blue-green-red and the embedder against red-green-blue. Both entry points take ordinary pixels and reorder for the network, so a caller never has to know. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19766replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+Read a model without a schema, and prepare it once

ONNX is protocol buffers, and protocol buffers can be walked without one: every field carries its number and its wire type. Reading only the fields these graphs use -- nodes, attributes, initialisers -- takes a few hundred lines rather than a generated library, and a truncated message answers an error rather than reaching a panic. What a model declares is not what a kernel wants, so loading permutes each convolution weight into the matrix a channels-last product consumes, turns every batch normalisation into the affine map it is at inference and folds that into the operator in front of it, and drops what is an identity once the activation is already channels-last -- which is the whole transpose and reshape pair a detection head ends with. The subset is narrow and says so. A dilated convolution, a resize that is not an exact doubling, a pool that is not two by two: each is refused by name at load, so a model outside the subset fails before a scan starts rather than in the middle of one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19746replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Give the library kernels that reach the vector unit at run time

A binary shipped for a stock target leaves most of the machine idle: safe Rust will not contract a multiply and an add into one instruction, and compiling for the host is not an option when the host is unknown. So the kernels sit behind a feature check, in two forms, and the caller picks by matching on what was detected. Passing the work as a value is what keeps this to one unsafe token. Task names every kernel with its arguments, so a single specialised dispatch carries the whole set across the feature boundary. Each kernel keeps its own frame there rather than being folded into that one body, which costs three quarters of the throughput when it happens, because the register allocator then gives up on holding the accumulator in registers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19739replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Answer a search without an executor under it

Discovery is one socket, three message shapes and a thread that blocks on a read. `Responder` is async over tokio, so a binary that wants no runtime could not use it, and a photo server on a home network is exactly such a binary. `SyncResponder` is the same protocol over `std::net`. Neither wraps the other; they share the messages, which is where the protocol actually is. A read that times out answers `Ok(None)` rather than an error, which is what lets a serving thread look at its shutdown flag between datagrams, and `try_clone` gives the announcing thread its own handle on the socket the reader is blocked on. The group has to be joined on every interface a television might be on, and the standard library will name none of them. `local_interfaces` answers with the route this machine would take to the group -- a connected UDP socket chooses a route without sending anything -- followed, on Linux, by every address the kernel's routing table calls a local host route. `join_every_interface` is best effort by design: one unusable interface on a machine with three must not stop a set on the other two from finding anything, so a refused join is logged and the count of the successful ones comes back. Two limits are documented rather than worked around. A multicast announcement leaves by whichever interface the kernel picks, because `IP_MULTICAST_IF` is out of the standard library's reach and this crate carries no socket-options dependency; on a machine with one network, which is what a household has, it is the same interface either way. And two processes still cannot share port 1900, for the same reason `SO_REUSEADDR` was already out of reach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19723replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say what sits at the other end of a discovery

SSDP hands a control point a `LOCATION` and stops there. `upnp` is what the control point finds when it follows it: the device description document, the service description behind each `SCPDURL`, the SOAP an action is invoked with, and the DIDL-Lite a `Browse` answers with. Pure primitives throughout. Nothing here opens a socket, reads a file or knows what a library is -- the caller routes its own HTTP, hands the request body to `Action::parse`, builds the answer out of `didl` types and writes it back. That keeps it usable from a synchronous server, an async one, or a test with no server at all. Three things are held apart deliberately, because each is a place where a media server is quietly wrong and the television is the only thing that says so. A service is named by a *type* and, separately, by an *identifier*. They look alike, they are not interchangeable, and a description that swaps them is accepted by some control points and silently ignored by others. `Service` takes both and `Device::media_server` fills both in from the constants, so the pair cannot come apart. The DIDL-Lite a browse answers with is a string inside a SOAP argument, so the whole document is escaped once on the way out. An unescaped ampersand in a file name breaks the outer document, and a doubly-escaped one shows up on the set as `&amp;`. Titles, URIs and every attribute go through one escaper, and a character XML 1.0 does not admit at all is dropped rather than written. `protocolInfo`'s fourth field is where a set decides whether it will play something. `DlnaExtras` holds the profile, the seek operations, the conversion indicator and the flags apart, so that a caller trying profile strings against real hardware edits one table rather than a dozen format strings -- and a resource whose profile is not confidently known says `*` rather than guessing, since a set given a profile that does not match its bytes fails in a way that is hard to read. The SOAP reader is a scanner, not an XML parser: the body is machine-written, one level deep, and always the same six lines. It survives another envelope prefix, empty argument tags and whitespace between them, all of which are on real networks. `Search` is absent from the ContentDirectory description, because a service that lists an action must implement it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WshScW5JbJWEePekcGhsHm

64 days agor1870400018:19719replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Send a static site less weight, and stop giving it a cookie

Three things a static vhost was doing that it should not, all measured against a live one. Nothing was compressed. Every response went out raw however loudly the client asked otherwise, so a megabyte of base64-heavy markup was a megabyte, and a WebAssembly module was the whole module. The encoder now runs at the last point before the wire, so one rule covers every response the server produces -- a static file, a rendered page, a JSON answer -- rather than each producer having to remember. Measured on real payloads: markup to 0.30 of its weight, a module to 0.37, a script bundle to 0.17. The encoding runs on the blocking pool, because a single-core host has one async worker to starve. The conditional path had to learn about it. A `304` is answered about the copy the client actually holds, so either form of the tag settles it and the one that matched is the one echoed back -- and a client holding the encoded copy that then asks for no coding gets the whole file rather than a `304` that would have it read a gzip member as markup. Every response also carried a session cookie, on a vhost with no database, no authentication and nothing dynamic in it. `.wasm` files carried it. A session identifier is a key prefix into the vhost's own database and nothing else, so a vhost configured without one had nowhere to keep what it issued: the commands that would use it already answer "no database available". The cookie was not merely useless. No shared cache will store a response that carries one, and a cookie set for no purpose is one the operator has to account for to whoever asks what it is for. A vhost with no database now mints none. And an asset whose filename carries a content hash may be held for a year and marked `immutable`, because the name is a promise the bytes cannot change under it. The test is narrow, since a false positive means a browser holding a stale file for a year: eight or more hexadecimal characters standing as their own segment, with at least one numeral and at least one letter, so `deadbeef.js` is read as a word and `20260728.json` as a date. Entry documents are excluded whatever they are called -- a deploy that changes one must be seen, and a document is the thing a reader has bookmarked. The three settings are `#[optional]`, so a configuration written before they existed loads unchanged and comes up with compression on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19706replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

33 operations, since the previous mark · compare with the head
+Say what a client will accept, and send it that much less

A response sent raw costs the reader every byte of it, and markup, script, stylesheets, JSON, SVG and WebAssembly are all highly redundant: a page built of them travels at two to three times the weight it need travel, paid by whoever is on the slowest connection, on every first visit. `http::encoding` is the whole of content-coding negotiation and the gzip stream itself. The enum is exactly the set of codings the crate can emit, because naming one it cannot produce would let negotiation promise something the wire could not keep. Negotiation follows RFC 9110 12.5.3: `q=0` refuses a coding, and that applies to `identity` as much as anything; `*` speaks for what is not named; weights compare in thousandths, the full precision a qvalue has, so none compare equal by rounding. Two choices are ours rather than the specification's -- a request with no `Accept-Encoding` is answered with no coding, since such a request is rarely a browser and an unrequested coding breaks a script for no gain, and a tie goes to gzip, which is the reason the negotiation is being done. Eligibility is by media type. Everything under `text/` is text by definition whether or not the subtype is modelled, script under any of its four names is script, and a module is mostly LEB128 indices and loses two thirds of its weight. Nothing that carries its own compression qualifies, because a second pass spends the processor and adds bytes. `encode` reads a body named as a window of a file, since the bytes going out are no longer the bytes on disk. `Content-Length` is taken from the body at the moment of writing, so it describes the encoded body by construction rather than by anybody remembering -- a length that does not match is not a cosmetic fault but a client waiting for bytes that never come, and every message after it read at the wrong offset. `Vary` is set whether or not this response carried a coding, because the ones that did not are exactly the copies a shared cache would hand to a client that does accept one. And the entity tag gains the coding inside its quotes: an encoded body is a different representation, and sharing one validator is how a client comes to render a gzip member as markup. The encoder is put to `gzip(1)` in both directions. A codec checked only against its own decoder agrees with itself, which is something a consistently wrong codec does just as well. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19672replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Drive a sync from either end, and prove it converges

A message set and a subtraction are not a protocol until something decides what to say next. A session is that: feed it what arrived, take the messages it hands back, read where things stand. It holds no log and no connection -- the caller passes the log on each call -- so the same code runs in a browser and on a server, and a caller loops it over any pipe at all. Either side may start. A session handed an opening before it has made one opens on its own account in the same turn, so a peer that was called upon needs no separate path and two peers that both open at once do not open twice. Both sides run the same code, which is what makes a server a relay rather than an authority. A session is converged when it has said everything it owes and heard the other side say the same. The tests hold it to more than that: two logs diverge -- a clone, two histories with nothing in common, one side far ahead, both sides a little ahead, sixty random small divergences -- and afterwards they must hold the same operations, the same frontier, and the same record under every name. The pipe carries bytes rather than values, so every message is encoded where it is sent and decoded where it arrives. Two of them measure rather than assert. Over 204 operations differing by eight, the walk spends 29,608 bytes and moves 408 operations where the sketch spends 2,142 and moves 8; tripling the shared history leaves the sketch at 2,142 exactly. That ratio is the whole argument for the second mode. And a sketch sized for nothing against a difference of four hundred stalls, falls back, and converges anyway -- with the fallback still readable afterwards, because a caller wants to know its estimate was low. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19661replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Reconcile two logs by their difference rather than their size

The frontier walk cannot subtract a head it has never seen, so two peers that have both written since they last spoke each offer their whole history to learn that a handful of operations changed hands. Over a repository that syncs often, which is the case worth optimising, that is the wrong cost. An invertible Bloom lookup table is a sketch of a set whose size is chosen from the expected difference and not from the set. Subtract one peer's from another's, peel what remains, and both halves of the symmetric difference fall out. fe2o3_data has the primitive; what is added here is what a sketch of a history is. A table needs a fixed-length key and an operation name does not have one: its encoding is two varints, so a name spends between two and twenty bytes. The sketch key is therefore its own spelling, sixteen bytes, the replica in eight big-endian bytes and the counter in eight more -- big-endian so that key order is identifier order, which costs nothing and makes a dumped table legible. Sizing follows the primitive's own rule, three cells per two expected names at three hashes, with a floor under it because one and a half cells per entry is an asymptotic statement and a handful of keys in a handful of cells stalls too often to be worth a round trip. Two things a caller might have expected to configure are not configurable. A receiver builds its table under the shape the arriving one declares, seed included, so peers that estimated differently still reconcile and there is no agreement to get wrong. And a decode that stalls is a typed outcome rather than an error: the partial difference it recovered is exactly the arbitrary subset that must never be sent, so it is discarded and the reason is reported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19649replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Say what one peer tells another, and what it is owed

A log that has been edited in two places is two logs, and neither knows what the other is missing. The first half of working that out is a vocabulary and a subtraction. The vocabulary is four messages -- a frontier, a sketch, a batch of operations, and an end -- each with a daticle form and a byte form behind a magic and a version byte, because these bytes cross between machines built at different times. A frontier is a set, so it is spelled ascending and without repetition and a decoder refuses any other spelling; a batch carries bare records or sealed envelopes, tagged, since a repository that starts unsigned and later gains signatures should not need a second protocol. One encoding is frozen, for the same reason the segment's is. The subtraction is the frontier walk. A peer says what its frontier is; everything it holds is an ancestor of one of those heads, so the operations it demonstrably holds are the ancestors, within our log, of the heads we can also see, and what it is owed is everything else. That is sound and it is not tight: a head we have never seen tells us nothing, so where both sides have written since they last spoke, each offers its whole log and the receiver drops what it already has. Looseness costs bytes and never correctness. What it must never cost is a hole. An operation set missing one of its own parents renders differently from the same set once the parent arrives, so what is sent is closed against what the receiver holds before it goes, and checked again on arrival: arrival_gap names the operation and the parent nobody has, and a batch with a hole in it is refused whole rather than half absorbed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19643replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Give a segment entry a daticle spelling of its own

A segment writes an entry as a kind byte and then an untagged body, because the digest beside it covers both and re-encoding to check would be a second answer to the same question. That shape is no use to a carrier that is itself a daticle: a sync message holds a list of entries, and a list of pairs of a byte and some bytes is not a list of anything a decoder can name. Entry::to_dat writes [kind, body] and Entry::from_dat reads it, refusing a tag that is neither form. The segment framing is untouched, and its frozen bytes say so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19634replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Name the types a web application is actually made of

A WebAssembly module has no entry in the media table, so it leaves the static file handler as text/plain, the type an unknown extension gets. A browser handed that refuses the response to compileStreaming and buffers the whole module before compiling any of it, which is the one thing the streaming API exists to avoid. The same silence covers the manifest that makes an application installable, and it is served as text/plain too. application/wasm and application/manifest+json join the Application subtypes, image/webp (RFC 9649), image/vnd.microsoft.icon and the image/avif already modelled get extensions, and the extension map gains json, pdf, zip, webp, avif, ico, tif, tiff, txt, csv, xml and mjs. text/xml carries a charset because RFC 7303 makes it US-ASCII without one. Two formats answer to more than one name and both are now read while only the registered one is written: WAVE, which IANA registers as audio/vnd.wave and lists as audio/wav and audio/wave, and which the WHATWG mime sniffing standard emits as audio/wave; and the favicon type, registered as image/vnd.microsoft.icon and sent by everybody as image/x-icon. A proxy that refuses the name an upstream chose forwards nothing. The oracle for the spelling is the IANA media types registry, cited per entry, since a type spelled a little differently is a type the receiver does not recognise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19631replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Give the text crate the base64 everybody else speaks

The crate had a Base64 already, as a Base2x alphabet, and its own docs say what it is not: the padding scheme is Base2x's fixed three characters, so a string it writes does not survive atob and a string a browser writes does not survive it decoding. Every caller with an RFC 4648 obligation -- the DKIM signer's b= and p= tags among them -- reached past it to an external crate, which seven Cargo.toml files in this workspace now name. base64 is that encoding and nothing else: the A-Za-z0-9+/ alphabet of RFC 4648 section 4, = padding out to whole four character quanta, encode from bytes and decode back to them. The decoder refuses rather than guesses, because two decoders that disagree about one string are how a signature verifies at one end and not the other. A length that is not a multiple of four, a character outside the alphabet (whitespace and the URL-safe substitutes included), a = anywhere but last, and a final quantum whose unused bits are not zero -- section 3.5 permits rejecting that one and this decoder does, matching the crate it is meant to replace. Each refusal names the offending character and its index. The oracles are external, since a codec tested only against itself agrees with itself however wrong it is. Section 10's seven vectors verbatim; fixtures generated by base64(1) with the commands recorded beside them; and a differential run against the base64 crate over 200 lengths, which is the evidence that dropping that dependency changes nothing on the wire. Checked by mutation: an alphabet switched to the URL-safe one fails four of the nine tests, including both external oracles. The dependency swap itself is left alone -- dkim.rs and the other six callers are a separate change, and this one adds no behaviour to existing code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:19612replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Point the diff module's doc link at the operation it now yields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19607replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Port the file-identity cases into ore's own tests

The multi-file oracle's twelve cases, each with the answer it predicted before it ran, as crate tests: a cross-file move carrying a concurrent edit, a move racing an edit at its destination, two branches creating one path, a file deleted around a move both ways round, a content delete that destroys what moved out, cycles of length two and three across files, an insertion racing a rename, content moved into a deleted file, a move chained through a third file, and a move naming a file's origin anchor. Every case runs under every delivery order and checks conservation repository-wide, which is what the exercise was for: no byte in two files at once, and no live byte nowhere. CrossedFile compares where content was written against where a demoted placement put it, rather than the two ends of the demoted origin. Once a two-file cycle has collapsed both ends are in the same file, so the latter reports nothing while a file sits empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19605replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Name a file by identity in ore, and place every operation by content

The vocabulary named a file by its path, which is a mutable label: the association between an operation and a file was reconstructed rather than recorded, a path could not be spelled unless it was UTF-8, and a move between files could not be delivered at all. Candidate B of the file-identity options note, settled after the multi-file oracle trial. A content operation now names only content: Splice and Move lose their file field entirely. FileCreate mints the file, its own identity being the file's identity, and with it the file's origin anchor -- one byte of content born dead, named <create>+0 -- so that an empty file is not empty in identifier space and a splice into one anchors after a byte like every other splice. FileRename and FileDelete name a file by that identity and carry a path as bytes. The rule that replaces the file field: an operation that places anything must carry at least one origin, since that origin is what says where it lands. The decoder enforces it, because an operation that satisfies neither belongs to no file and no later stage could decide one for it. The sequence becomes the repository. One Fugue forest whose root children are the origin anchors; a file is the subtree beneath one, so a slot's file is read off the tree rather than off the record, cross-file move needs no routing, and a concurrent edit inside a moved range follows it across a file boundary. Conservation is checked repository-wide, which is where it bites: a byte rendered in two files at once is what a per-file claim register produces and no per-file check would see. Two flags added and one corrected. CrossedFile says a cycle demotion carried a placement into another file, naming both. MovedIntoDeleted says content landed in a file that has been deleted. Torn now consults causality: only a concurrent claim tears, so a move superseded on purpose by a later move of the same content is no longer reported as a race. Header's fields become private, canonical parents being an invariant rather than a convention. Formats: ORESEG and ORESNP go to version 2. A snapshot keys its files by identity, not by path, so two live files may share one; paths are bytes. Golden bytes re-frozen by hand and annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:19582replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

803 operations, since the previous mark · compare with the head
+Recover the edit between two versions of a file

An author working in an editor supplies intent directly; an author working in a filesystem supplies two versions of a file and nothing else. The engine had no way to turn the second into the first, so a frontend was left trimming the shared prefix and suffix and recording everything between as one splice -- which stores the whole file the moment two edits are made far apart in it. The diff module works in two levels. Lines first: the bytes are cut at every newline, each distinct line is numbered, and Myers' O(ND) algorithm runs over the numbers, so the cost follows the size of the difference rather than the size of the file. Bytes second: each run of changed lines is trimmed of what its two sides share and, where what remains is small, Myers runs again over those bytes, so a one character change stores one character. Both passes are capped, at 1024 line edits and 256 byte edits, since Myers costs O(D squared) in memory as written here. A line script that would exceed its cap falls back to exactly the single trimmed splice the frontend used to produce, so the pathological input is no worse than it was and is found in linear time. The splices are positional, and every position is an offset into the old bytes: turning an offset into a content anchor needs a render, and the render is what the caller holds. Splices are ascending, non-overlapping, and kept at least sixteen unchanged bytes apart, so no splice anchors on content another one removes. The tests assert one property universally, over every case and 280 randomised pairs: applying what the diff returns to the old bytes yields the new bytes exactly. On a thousand line file with two distant edits, that comes to 32 inserted bytes rather than the 35,216 the trimmed splice would have stored.

64 days agor1870400018:18778replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Close the last way to build a reversed content range

A ContentRange refuses an end that precedes its start, but its bounds were public, so a struct literal could build the range the constructor exists to forbid. The length and the offsets subtract the start from the end, which on such a range is a panic in a debug build and a wraparound in a release one. The bounds are now private, read through const accessors, and the one thing that legitimately moves an end -- coalescing an abutting run -- goes through set_to, which re-checks the invariant. Of the other names in the module, ReplicaId already hid its value and OpId, ContentId and Anchor carry no invariant for a literal to break, so none of them needed the same treatment.

64 days agor1870400018:18773replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

53 operations, since the previous mark · compare with the head
+Say that a record's hash and a segment's digest are different values64 days agor1870400018:18719replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Let a writer continue a segment already written

A segment is a header and then records to the end, so appending to one on disk is writing records with no header in front of them. The writer could only start a segment, which left a caller with a segment already written encoding a whole one and slicing off as many bytes as a header of the same shape happens to occupy -- arithmetic that is right only for as long as the two headers agree, and that says nothing about whether the bytes being appended to are readable. Writer::resume reads the existing segment first, under the hasher and salt it is given, and then emits the new records alone. A different hash function, a different salt, another format version and a segment left half-written by an interrupted append are all refused there, before anything is added to bytes nobody could get to the end of. The count carries on from the records already in the segment.

64 days agor1870400018:18717replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Merge two branches of a file's history in one call

The state of a sequence is its operation set, so a merge is the union of two sets -- but taking it needed a caller to walk the other sequence, ask it for each operation's parents, rebuild the Header those two make and apply it, which is a page of code for a set union and one that quietly loses an operation's parents if parents_of is answered with None. Sequence::absorb takes the other set whole and says how many operations were new, so a caller with nothing to add can leave the render it already has. An identity naming a different operation in each sequence is refused and nothing is taken: two sets that disagree about what an identity means are not two branches of one history. Nothing is asked of the two sets causally, because a sequence holds one file of a repository and routinely names parents it will never hold; closure stays where it was checked, at the render.

64 days agor1870400018:18708replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give an Edit a way back into the durable vocabulary

Edit::from_op takes the file off an operation on the way into the sequence structure. Nothing put it back on, so a frontend that authored through Rendered::splice or Rendered::move_range had an Edit and had to write the match into an Op itself, once per caller, with the two variants' fields spelled out each time. Edit::into_op is that match, the exact inverse of from_op, and the crate's own wire test now uses it instead of a copy of it.

64 days agor1870400018:18705replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Mint operation counters as a Lamport clock across every replica

The op order in seq is the pair (counter, replica) ascending, and the intuition it is read with -- a later edit wins -- holds only where the counter is one greater than the greatest its author has seen, from any replica. OpLog minted one past the authoring replica's own head instead, so two edits at the same counter were decided by the lower replica number, which is the reverse of what happened whenever the higher-numbered replica wrote first. OpLog::next_counter is now the log's greatest counter plus one, and next_id mints against it. Nothing is given up: the append guard insists a replica's own counters strictly increase, and the log's greatest is at least that replica's head, so the guard is untouched. What a replica loses is consecutive counters, and gaps were always permitted. The later-edit-wins intuition is now a test: replica nine writes, replica one absorbs that and answers it, and the answer orders after under OpOrder where per-replica minting would have put it first.

64 days agor1870400018:18701replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Merge branch 'ore/format'64 days agor1870400018:18686replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Judge a sequence's causality by the repository's graph

A sequence holds one file, but an operation's parents name the frontier of the whole repository, so a file of a multi-file repository is written against operations the sequence will never hold. Checking its precondition against its own operations therefore refuses every history with a second file in it. Rendering now takes the graph it judges by. Sequence::render_with is given one, from the log or wherever else the whole history is held, and Sequence::render is the special case where the sequence holds all of it. A graph that does not describe an operation the sequence holds is refused rather than guessed at.

64 days agor1870400018:18685replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Tidy the segment truncation sweep

The loop kept a variable it no longer read.

64 days agor1870400018:18677replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Name the two formats in the crate's layout, and sweep snapshots randomly

The crate documentation now lists the segment and the snapshot alongside the modules that were already there. A randomised sweep encodes snapshots of any number of files carrying any mixture of runs and flags, decodes each back to exactly what went in, and requires every truncation of every one of them to be a typed error.

64 days agor1870400018:18672replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give the rendered state a snapshot format

A snapshot is what the repository looked like at a frontier: the operations the state includes everything up to, and per file the rendered bytes, the provenance of those bytes and the flags the render raised. A reader can take a snapshot and then only the segments holding operations the frontier does not cover, rather than replaying a history from its first operation. The provenance is carried because without it the bytes are dumb. A frontend that means to author a content-anchored splice against what it is showing has to know what the byte under the cursor is called, and a run is what says so. Flags, origins and runs gain codecs to make this possible, each with wire codes of its own. Both lists in a snapshot are sorted and free of repetition, and the decoder refuses the spellings the constructor normalises, so one state has one byte spelling. Truncation at every offset is a typed error, and the bytes of a small snapshot are frozen against accidental change.

64 days agor1870400018:18669replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Give the log segment a byte format

A segment is a header and then one length-prefixed record after another, each tagged as a bare record or a sealed envelope so that a repository which starts unsigned and later gains signatures needs no second format. The header carries the magic, the format version and an optional replica hint, which lets a reader sort a directory of segments without opening them. Each record carries a digest over its kind byte and its body, computed by a hasher the caller brings, so the crate still chooses no hash function. A corrupted record is refused with an error that names its ordinal and, where the body still decodes, the operation it carries. The reader is incremental in the style the fast-import parser established: feed whatever bytes have arrived, take records as they complete. Feeding a segment one byte at a time yields exactly what feeding it whole yields, truncation at every offset of a segment is a typed error rather than a panic, and a hand-frozen byte sequence fails the moment the format changes by accident. The stand-in signer moves out of the envelope tests into a test support module, which now also carries a stand-in hash function short enough to write down.

64 days agor1870400018:18661replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Make every operation content-anchored and give it parents

The durable splice named a byte offset while the sequence structure consumed content anchors, so only a move could cross between them. Op::Splice now carries the same anchors, ranges and payload that seq::Edit::Splice does, and the positional form is gone; Edit::from_op bridges the whole vocabulary, returning None for the operations that say nothing about the order of bytes rather than failing on them. Wire codes are renumbered so that the two content operations sit together at the end. Every operation now records the frontier its author could see. The parents live on a new Header, shared by all six variants rather than repeated in each, and a Record pairs that header with the operation; the envelope seals the record, so a causal claim is covered by the signature along with the edit. The log learns what the parents are for. It refuses an operation whose parents have not arrived and names the one that is missing, absorbs a shuffled batch and hands back what it cannot place, reports the frontier as the operations nobody names as a parent, and answers whether a set is causally closed. Causality is the graph itself, borrowed rather than copied, and it answers reachability and concurrency. The sequence checks its precondition against real parents, and the Overlap flag now means proven concurrency rather than region overlap: two operations that touched the same bytes where one saw the other are a sequence of edits, not a conflict. The ten adversarial cases render exactly what they rendered before.

64 days agor1870400018:18653replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

286 operations, since the previous mark · compare with the head
+Merge branch 'fix/o3db-rollover'64 days agor1870400018:18366replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+o3db: bring the rollover test header up to date with its phases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18365replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+o3db: register the supersession when a reloaded copy is the older one

Cache::insert rejects a value stamped no later than the one already cached and returned None, which the cbot reads as "nothing was superseded". The copy just offered is superseded, though, so its record stayed flagged current in its file state and its bytes were never reclaimable. It happens on every restart: the igbots rebuild the caches from the index files concurrently, so records reach a cbot in file order per igbot but in no order between them, and every copy that arrives after a newer one for the same key leaks. A churn of 520 records over 40 keys, restarted and churned again, left 332 of 1040 records permanently unreclaimable. Insert now returns the offered location in that case, so the record is tracked and immediately flagged old. A copy at the location already cached is the same record arriving twice, not a supersession, and is still left alone -- flagging it would retire live data. The per-record warning becomes a trace: during a reload this is the ordinary case, not an anomaly. Two things that only surfaced once collection started running properly on sealed files: - FileBot::gc_active removed the gc buffer entry after replaying it, which took with it the new buffer created if a replayed message started a fresh collection of the same file. Later messages then went straight through to a file mid-transcription and could set a second collector on it, which lost a race over the same temporary file. The buffer is now taken out before the replay, and the remainder of a replay is held over for a collection that starts during it. A file whose collection is already active no longer passes the trigger test either. - A zone survey failed outright on any file name it could not parse, including ozone's own abandoned garbage collection temporary, which an interrupted collection leaves behind. The survey now removes such a temporary (the data file it was copied from is intact) and warns past anything else it does not recognise. ZoneDir gains relative_gc_temp_path and is_gc_temp_file so the collector and the survey agree on the name. The rollover test gains a restart phase covering all of this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18360replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+o3db: prove reclamation against a collection-off oracle

The rollover test's garbage collection phase now runs the same overwrite churn twice on a freshly wiped database, once with collection off and once with it on, and requires the collected store to be well under a quarter of the uncollected one. Measuring the same workload with the only byte-reclaiming mechanism disabled means the comparison cannot be satisfied by anything other than reclamation actually happening. 1600 writes of 40 keys leave 144,000 bytes of data files with collection off and 4,140 with it on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18342replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+o3db: a ping now reports the bot's error tally

OzoneMsg::Pong carries the responding bot's error count, so a caller can tell a bot that is alive from one that is alive and failing. Errors raised inside a bot's own listen loop are logged rather than returned, which previously left no way for a caller -- or a test -- to observe them. OzoneApi::bot_error_count sums the tally across every bot. The rollover test uses it to assert directly that a heavy overwrite workload over many sealed data files raises no bot errors at all, which is the log storm the rollover fix removes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18321replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+o3db: stop a zone handing out a live file number already in use

ZoneBot::survey_files handed the writer bots their live files and only then set the zone's live file number counter, overwriting the value init_writer_live_files had advanced. On a fresh zone the counter went back to zero while the writers held files 1..n, so the first n rollovers each asked for and were given a file number that was already being written to. On a restart the same happened through the size-ratio wind back, which pushed the counter one below the incomplete file just handed to a writer. The receiving fbot treats a new live file number as new: FileStateMap ::new_live_file replaced the whole FileState, throwing away the record start positions for every record already in that file. Every later supersession of one of those records failed in FileState::register_old with "a data entry starting at position N in the FileState was not found", so those bytes were never counted as reclaimable and the store grew without bound. Where two writer bots share a zone the reissued number could also be another writer's live file, putting two writers on one file at once. Three changes: - The counter is seeded with the highest file number on disk before the writers are given their files, and init_writer_live_files leaves it above every number it assigns, so the next live file number is always unused. The obsolete wind back goes with it. - new_live_file is made non-destructive: a file already known to the shard keeps its record map and only gains the live flag. - register_old looks the entry up instead of inserting it. BTreeMap ::insert writes even when it returns None, so a failed flagging was leaving a spurious old entry behind that the old-record counters never saw, and the two disagreed from then on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18311replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+o3db: reproduce the lost FileState entries at data file rollover

Adds tests/rollover.rs, which drives many rollovers with a 4 KiB data file limit while repeatedly overwriting a small set of keys, so that supersessions land in files that have already been sealed. It asserts the supersession accounting invariant: one record map entry per record written, exactly one current entry per distinct key, and an old-record counter that agrees with the number of old entries in the map. The test fails on this commit, with 40 copies of the field error "a data entry starting at position N in the FileState was not found" raised from FileState::register_old, all naming file 1. To support it, OzoneApi::collect_file_states returns the gathered file state maps rather than only logging them (dump_file_states now calls it), and FileState gains a get_old_count getter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18292replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Merge branch 'feat/png-coverage'64 days agor1870400018:18283replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Take the PNG scanline slices through get rather than indexing

The pass arithmetic sizes the inflated buffer for exactly the reads the unfilter loop makes, so the indexing was sound. It was sound by a chain of reasoning three functions long, though, and an indexing panic is not a refusal: a decoder whose contract is that it names what it will not read should not be one edit away from aborting the process instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18282replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Test the PNG depths and interlacing against ImageMagick

The only PNG the encoder writes is eight-bit truecolour with alpha, not interlaced, so a round trip through it cannot reach any of the forms the decoder has just learned. Sixty-eight fixtures therefore come from ImageMagick, written from sources this crate never saw, and each is compared against ImageMagick reading its own file back as a PAM. The matrix is every legal depth and colour type with and without Adam7, plus the extremes of the format at 1 by 1, 3 by 2, 5 by 1, 1 by 5, 9 by 9 and 17 by 13, so that passes which fall empty are exercised rather than assumed. Eight bits and below are compared exactly. The sixteen-bit fixtures are allowed one level a channel, because the two reductions differ and neither is wrong: this codec keeps the high byte, ImageMagick truncates v * 255 / 65535, and the difference of the two floors is under one for every u16. The fixture sources deliberately hold samples that are not multiples of 257, so the tolerance is exercised rather than merely allowed for. Each case declares the depth, colour type and interlace method its name claims, and the test reads the fixture's IHDR to check the file really carries them. ImageMagick silently ignores a png:bit-depth it cannot honour -- it did so for half the recipes on the way to these -- so without that check the matrix could quietly become sixty-eight copies of the eight-bit case and still pass. A second test requires every fixture to hold two colours, every tRNS fixture to hold a transparent pixel, and every one-bit fixture to reach both 0 and 255, which caught a 1-bit palette fixture that had quantised to a single entry. The unit tests take the parts an oracle cannot reach: that the seven Adam7 passes name every pixel exactly once at every size from 1 by 1 to 20 by 20, that the expected size sums the passes, that a stream of the non-interlaced length is refused, that a row's padding bits stay out of the pixels, and that the header refuses each method byte and each depth the colour type does not allow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18279replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

277 operations, since the previous mark · compare with the head
+Read every bit depth and Adam7 interlacing in the PNG decoder

A photo library turned out to hold two forms this decoder refused: 69 Adam7-interlaced slideshow frames and three 16-bit-per-channel phone screenshots. Both were legal PNG, both were named in a NoImpl refusal, and neither could be shown. The sub-byte depths were refused on the same test and are here for the same reason. The decoder now reads all five depths across all five colour types, with or without interlacing, and the product of the two rather than each alone. A non-interlaced image is decoded as a single pass with a step of one, so the interlaced and plain paths share every line below the pass list. The size ceiling had to follow. Adam7 writes seven passes, each with its own scanlines and so its own filter bytes, and an interlaced image carries more filter bytes than it has rows: 112 rather than 64 for a 64 by 64 greyscale image. The expected size is now summed over the passes, and a pass whose grid falls outside a small image is dropped rather than carried as a zero-sized one, since such a pass contributes nothing at all to the stream. A stream of exactly the length the image would have taken without interlacing is now refused, which is what a decoder that got this wrong would accept. Samples narrower than a byte are widened so the widest value the depth can hold becomes 255, and the row's padding bits are masked away rather than merely left unread. Sixteen-bit samples are reduced to their high byte, as libpng's png_set_strip_16 does: a deliberate loss, exact for any sample that is an eight-bit value written twice, and off by at most one part in 256 otherwise. A Pixmap is eight bits a channel, and a lossless path would be a second pixel type rather than a change here. A tRNS sample is now held at the file's own depth and compared against the raw sample before widening. Comparing widened values would make every black pixel of a one-bit image match a tRNS of zero whether the file said so or not. ImageMagick agrees with this reading of a 4-bit greyscale tRNS file; Pillow does not, and reports it fully opaque. Three refusals are new, and were missing rather than deliberate: a compression method other than DEFLATE, a filter method other than the adaptive one, and a depth the declared colour type does not allow. The inflation buffer also now starts at a megabyte whatever the header claims, so a handful of bytes declaring a large image cannot make the decoder reserve a large allocation before any of it is inflated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:18001replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

59 operations, since the previous mark · compare with the head
+Make the all feature expose what it links64 days agor1870400018:17941replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Read the machine's own clock setting before guessing at it

Local zone detection read the TZ variable and nothing else, so a desktop with TZ unset was answered with UTC -- silently, and wrongly by eight hours on this one. Detection now follows what the platform actually provides: TZ as POSIX defines it (a name or a TZif path, with an optional leading colon), then /etc/localtime read as the TZif file it is (the symlink target naming the zone, the bytes carrying its rules), then Debian's /etc/timezone name file. Naming a zone the embedded table does not hold was also answered with a silent zero offset. CalClockZone::new now consults the system zoneinfo tree before falling back, so Australia/Perth answers +08:00 wherever a tzdata is installed, and the embedded table is only load bearing on systems without one. The new test's oracle is the date command: local() must agree with date +%z, and a system-resolved zone with TZ=<name> date +%z, skipped where no zoneinfo tree exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:17933replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Merge branch 'ore/sequence'64 days agor1870400018:17924replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Say what the code does rather than which note said to do it

Two doc comments referred to documents outside the repository, which a reader of the crate cannot follow. Both now state the rule itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17923replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Add the sequence structure a move can be recorded in

The problem the published work leaves open is a range move: an edit made concurrently inside a run that another replica is moving should land in the run's new home, and in every list CRDT it does not, because insertions are anchored to positions and a move does not change them. The structure here anchors to content instead. Bytes take their identity from the splice that created them and keep it; position is a derived layer of slots, each claiming a run of that content and ordered against the others by Fugue over origins that name content rather than elements. A move places slots at the destination and claims the moved bytes for them, and a per-byte last-writer-wins claim register, an interval map per atom, decides which slot owns each byte. An insertion whose origin names a moved byte resolves through that register to wherever the byte now lives, so the edit follows the move with nothing written to make it. Two rules the design note did not state, both of which the convergence oracle found the hard way. Where a move has separated a slot's two origins the published parent rule is re-run against the left origin's current in-order successor, which is Fugue's Algorithm 1 with "the next element" read at render time; without it an insertion abutting a moved run lands at the end of the file. And a move whose destination names content the move itself claims is a cycle of length one, which the demotion rule must see, or the move's slots detach from the tree and their bytes are lost in silence. What cannot be made to converge on intent is made to converge and say so. A torn move, an origin demoted to break a cycle, two operations naming the same content: each is a flag in the render, each names the operations involved, and each is a function of the operation set, so two replicas holding the same history report the same flags. Rendering also checks conservation -- every byte created is rendered exactly once or is dead -- which is the one property that catches a slot quietly detached from the tree; it runs on every render in a debug build and on request otherwise. The state is the operation set and nothing else. Applying is set insertion, everything else is derived and rebuilt on demand, and a causally incomplete set is refused with the operation and the name it could not resolve rather than being guessed at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17920replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Make the reserved move operation real

A move is not a delete plus an insert, and recording it as one is what strands a concurrent edit made inside the moved run on a tombstone. The Move variant therefore names its source as content and its destination as an anchor, and carries no offset anywhere: nothing in it can be invalidated by another move arriving first. The source names bytes and not a file, because a byte's identity is repository-wide, so one operation covers a move within a file and a move between two; only the file field says where the run lands. Code 6, the same list-of-fields daticle shape as its neighbours, and the length-prefixed codec it inherits. The tests put a fragmented source, an empty one, both destination edges of a file and the anchor sides the sequence structure refuses through both round trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17904replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Name a byte by what created it rather than by where it sits

An operation identity already names a writer and a count. Three more names follow from it by arithmetic and are never minted: a ContentId names one byte by the splice that created it and the offset within that splice's run, a ContentRange names a run of them, and an Anchor names a gap by the byte on one side of it. The point of naming a byte this way is that the name survives the byte being moved. An edit anchored to content lands beside the same neighbour it was written beside, wherever that neighbour has since gone, which a position identifier cannot do. A splice inserting a thousand bytes brings a thousand names into existence for the price of the one identifier it already had. Each carries the JDAT round trip the operation vocabulary needs of it, and a Side that will not decode from a code it does not recognise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17885replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Let an interval map be asked what a range touches

Reading the intervals overlapping a range meant iterating the whole map, which is the wrong shape for a caller that holds one line per object and queries a short span of it. The new overlapping() seeks straight to the one interval that may reach in from the left and then walks the entries that begin inside, so the cost follows the answer rather than the map. Intervals come back as they are stored, edges included, because clipping them would throw away the fact that the ground continues; a caller that wants only the covered part clips. An empty or reversed range yields nothing rather than panicking on a backwards BTreeMap range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17878replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Check the stream parser against git rather than against itself

Hand-written fixtures only prove a parser consistent with the hand that wrote them. This test builds a scratch repository with the git binary -- a rename, a binary file with NUL bytes in it, a branch, a merge and an annotated tag -- has git fast-export write the stream, and asserts on what comes back: four commits, the rename intact as a rename, the merge carrying two parents, the binary blob byte for byte, and every file entry naming a mark some blob minted. It is ignored by default because it needs a git binary and a writable temporary directory. Run it with --ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17875replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Read git history through the one interface git keeps for outsiders

The fastexport module parses the stream git fast-export writes, which is the only documented way into a repository that does not depend on packfile layout, the reference backend or the object hash. Bytes in, typed events out: feed whatever has arrived, take events as commands complete, so a repository never has to be held in memory. No process is spawned and no file is opened here, which keeps the crate's wasm build intact. Commits, blobs, tags, resets and the housekeeping commands each become a variant of one enum, with identity lines split into name, email, seconds and offset, quoted paths unquoted back to their bytes, and payloads read by count so a blob holding NUL and bare line feeds passes through untouched. The interrogation commands -- ls, cat-blob, get-mark -- are refused by name, since there is nothing here to answer them; anything else unrecognised names the offending line rather than being skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17872replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Let the i64 getter read the unsigned 64-bit variants

map_get_i64 documents that any integer variant converts, but the getter macro had no U64 or C64 arm, so a u64 written through mapdat! -- a file size, say -- was unreadable. The new arms convert checked: above i64::MAX the getter refuses rather than wraps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:17868replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Let Zeller's congruence survive its own negative numerator

day_of_week() took % 7 of an expression that is negative for many ordinary dates (2004-03-15 among them), and the negative remainder fell through every match arm to unreachable!(). rem_euclid does what the algorithm meant. Weekdays in the regression test confirmed against date -d. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:17865replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Merge branch 'feat/http-range'64 days agor1870400018:17861replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Say in the range module that If-Range is not read, and what it costs64 days agor1870400018:17860replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Let a range test fail with an error rather than a panic64 days agor1870400018:17858replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Add SSDP: the three discovery messages, and a socket to speak them on

A television finds what it can play from by shouting into a multicast group and listening for whoever answers. That is all SSDP is: three HTTP-shaped messages over UDP, a start line and a block of fields and no body. `ssdp` models the search, the answer and the announcement, parses them and writes them back out. Parsing is forgiving about the things devices on a real network get wrong -- field name case, bare newlines, spacing -- and strict about the fields a message cannot be acted on without. A field this crate does not model is kept rather than dropped, so a vendor extension passes through. `Responder` binds the group on one named interface and reads and writes messages on it. One interface at a time on purpose: a socket joined to two groups cannot say which one a datagram came from, nor choose which one an announcement goes out on. Two limits are documented where they bite: a second process cannot share port 1900 without `SO_REUSEADDR`, which nothing here can set, and the outgoing interface is chosen by the bound source address rather than `IP_MULTICAST_IF`. The types and the parsing are tested against messages copied from real devices. Live behaviour on a real network is not, and wants a second machine rather than a unit test.

64 days agor1870400018:17854replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Serve a window of a static file, and prove it against curl

A `Range` on a static file now answers `206` with a `Content-Range`, a start past the end answers `416` naming the length that was missed, and every file served verbatim advertises `Accept-Ranges: bytes` -- which a browser looks for before it will offer a scrubber at all. The body is named rather than read. A file served verbatim, whole or in part, is copied from disk to the socket a chunk at a time, so a megabyte window of a two gigabyte recording costs a megabyte instead of the file. A document rewritten in development still goes through memory, because the file on disk is not the entity being sent and a window of it would not be either. A path that resolves to a directory is now a 404 rather than a 500: a directory opens like a file and reads like nothing. `tests/rig/range.sh` stands a real Steel up and drives it with curl over a ten megabyte file of known bytes, comparing every body against the same window cut with `dd`. A response of the right length carrying the wrong bytes is the failure a length check misses, and is what a viewer sees as a video that will not play. 168 checks, and the sweep runs again under `--http2`. If-Range is not implemented: a client that revalidates while seeking gets its window regardless of whether the file changed underneath it.

64 days agor1870400018:17849replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+Give a recording a media type, and stop writing the top level twice

A browser plays what the server says a thing is, not what its name suggests. Every video and audio extension was served as `text/plain`, so a recording downloaded rather than played, and no player would offer a scrubber over it -- which makes answering a `Range` beside the point. `MediaType::Video` is now a real variant with the seven subtypes anyone serves, and the extension map knows the video and audio names. `Audio`'s `Display` wrote its own top level as well as the one `MediaType` writes, so an MP3 went out as `audio/audio/mpeg`; it now writes the subtype alone, as every other subtype does.

64 days agor1870400018:17828replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+Answer a Range request: parse it, resolve it, and send a window of a file

A player asked to jump to the middle of a recording does not want the hour and a half before it. It asks for the bytes around the point, and asks again as it plays -- so a server that cannot answer earns no scrubber at all. `http::range` parses the field (RFC 9110 §14.1), resolves a specifier against a representation of known length, and builds the answers: `206` with a `Content-Range`, `416` naming the length the range missed, and the `Accept-Ranges` a browser looks for before it offers seeking. Several ranges at once are recognised and answered with the whole representation rather than a multipart body: nothing that matters asks for them, and the framing is one more thing to get wrong. A malformed field is ignored rather than refused, as §14.2 requires -- a client that garbles its Range gets the file. `HttpMessage` gains a body that is a window of a file, copied to the socket a chunk at a time. A two hour recording is a couple of gigabytes and the window a player wants is a megabyte; reading the file to answer costs two thousand times what the answer weighs. A window short of what its Content-Length promised is an error, not a short body, because a short body desynchronises every message after it on a kept-alive connection.

64 days agor1870400018:17811replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+Merge branch 'feat/exif-phash'64 days agor1870400018:17791replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Give fe2o3_hash two perceptual image hashes

Finding the near-duplicates in a large collection of photographs wants a cheap first pass and a slower confirmation, so phash provides both as sixty-four bit values. The difference hash reduces the image to nine by eight and compares each sample with its right hand neighbour. The cosine transform hash reduces to thirty-two square, transforms, and thresholds the low frequency block about the median of its alternating-current terms, the constant term left out of that median so overall brightness does not move every bit. Following the recipe the rest of the library keeps for primitives, the caller owns the decode: these take a luma grid, never a path and never compressed bytes. luma_from_rgb and luma_from_rgba convert for callers whose decoder hands back colour. The reduction is an area average, so a source a few pixels wider gives the same answer. The two kinds are distinct enum variants and distance refuses to compare across them, since the bit positions mean different things. The thresholds in the documentation are measured, not assumed. Over a spread of photographs put through a half-size reduction, a heavy re-encode, a brightening and a lossless to lossy conversion, a photograph never sat further than four from its own variants by the difference hash or two by the cosine transform hash, while the closest unrelated pair was nineteen and twenty-four. A ten per cent centre crop defeats both, and the documentation says so. The committed fixtures are synthetic subjects put through those same transforms by an external tool and stored as portable greymaps, which keeps the decode outside the crate. The tests assert the transforms survive, that unrelated subjects stay far apart, and that the two populations do not overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:17790replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

40 operations, since the previous mark · compare with the head
+Give fe2o3_file a reader for TIFF-structured image metadata

A photograph carries its date, its camera and its coordinates in a TIFF block, wherever it is found: inside a JPEG APP1 segment, as the file itself, or inside a box in the newer container formats. exif reads that block with no external dependency, in either byte order, following IFD0 and the EXIF, GPS and interoperability sub-IFDs, and the thumbnail IFD1 on the chain behind IFD0. Every standard field type decodes, ratios keep their exact terms, and a tag or a type the module has no name for is preserved as raw bytes rather than dropped, so a vendor extension that arrived later is still recoverable by the caller. PhotoMeta is the typed layer over the raw fields: the two capture times and their sub-second and offset parts, make and model, orientation, pixel dimensions, coordinates as signed decimal degrees with the hemisphere already applied, and the exposure basics. Exif::jpeg_dimensions reads the frame size from the start of frame marker, which is what a decoder will actually produce. Nothing panics. Every refusal names the byte offset and the structure that failed: a value block that runs past the end says which entry and where it pointed, a chain that returns to an offset already read says so, and a count of four thousand million ratios is refused before any allocation. Tests parse a hand-built block in both byte orders, sweep every truncation of it and of a JPEG, and drive the loop, count and offset failures deliberately. Two committed JPEGs carry metadata written by exiftool, one in each byte order, and the tests assert the values that tool reports back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uu3uceVR8JbrZjbRaWKCRf

64 days agor1870400018:17749replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Merge branch 'feat/jpeg-codec'64 days agor1870400018:17734replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Say in the crate's own documentation that it reads JPEG

The overview still described one codec, and the prelude exported the types a caller needs to encode a PNG but not a JPEG.

64 days agor1870400018:17733replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Estimate the coefficients a half-finished progressive file never sent

A progressive JPEG whose encoder stopped short of the last approximation bit, or whose later scans never arrived, holds blocks with no low-frequency detail, and rendered as they stand they show the flat squares of a photograph still loading. Annex K.8 estimates the five lowest AC coefficients of each block from the DC values of its eight neighbours -- a smooth surface fitted through the block means -- and applies the estimate only where a coefficient is still zero and no scan pinned it down. A file whose scans all completed is untouched. Two files in a library of thirty-five thousand needed it, and against them the samples differing from another decoder's reading fall by a factor of seventeen. While here, transform only the blocks that carry image. The MCU grid rounds a component's block count up, and the inverse DCT was being run over padding that the upsampler never reads and the crop discards.

64 days agor1870400018:17729replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Read the JPEGs a photograph library actually holds

Three faults surfaced only against real files, each decoded beside another implementation's reading of the same bytes. A JFIF segment outranks an Adobe one. A file carrying both, the second declaring no colour transform, is still YCbCr, because JFIF is defined as YCbCr; consulting the Adobe segment first turned such a file into false colour, red for blue, across the whole frame. The comparison that was meant to find the JFIF segment tested five bytes against a four-byte literal and so never matched at all. A truncated scan now leaves the rest of the picture flat mid-grey. A library holds files a failed copy cut short, and the reader pads with zeros past the end so that what did arrive still decodes; carrying on through that padding filled the tail of the image with noise. The bit reader now distinguishes reading ahead over the end -- which happens on every well-formed file -- from having genuinely run out. Vertical-only subsampling takes a filter of its own. 1x2 is rare but present, and libjpeg upsamples it with a vertical triangle filter whose two output rows round differently, not by replication. A plane only two samples wide has no interior for the horizontal filter to work over, and falls back to replication, as libjpeg's does.

64 days agor1870400018:17716replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

45 operations, since the previous mark · compare with the head
+Check the JPEG codec against ImageMagick's reading of the same files

A codec tested by round trip through itself proves only that it is self-consistent, which a codec that misreads the format is too. Every fixture here was compressed by ImageMagick from a synthetic source -- gradients, deterministic noise, saturated primaries, one pixel, and dimensions that are a multiple of neither a block nor an MCU -- and the pixels each is checked against are ImageMagick's own reading of it. Baseline and progressive, 4:4:4, 4:2:2 and 4:2:0, greyscale and restart markers are all covered. The tolerance is two levels a channel; the worst divergence across the seventeen fixtures is currently nought. The encoder is checked the other way round, by handing its output to ImageMagick and measuring the RMSE of what comes back against the source. The rounding of the chroma upsampler was wrong until this suite ran: the triangle filter's two outputs take different rounding terms, and at 4:2:2 the last column of every row came out of the wrong branch entirely. A round-trip test could not have seen it.

64 days agor1870400018:17670replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

73 operations, since the previous mark · compare with the head
+Give fe2o3_graphics a JPEG codec

The decoder reads baseline sequential, extended sequential and progressive frames at eight bits a sample, in greyscale, YCbCr, RGB, CMYK and YCCK, with any sampling factors and with or without restart intervals. Arithmetic coding, the lossless and hierarchical modes and twelve-bit samples are refused by name rather than misread. Every scan writes into a coefficient buffer and the picture is built once at the end, so the sequential and progressive paths share one entropy decoder rather than each having their own. Two further entry points fall out of that arrangement: a size probe that stops at the frame header, and an eighth-scale decode that reads the DC coefficient of each block and never runs an inverse DCT at all. The inverse DCT, the colour transform and the chroma upsampler are the fixed-point forms libjpeg uses, down to the rounding term, which is why the fixtures decode to the same bytes libjpeg gets rather than merely close to them. The encoder writes baseline sequential at a quality that maps onto the Annex K tables the way libjpeg's does, with 4:4:4, 4:2:2 or 4:2:0 chroma and a greyscale mode. A pixmap that is not opaque is composited over white, since JPEG carries no alpha channel.

64 days agor1870400018:17596replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Leave a magnitude no way to bring the process down

`Mag::unitise` and `Mag::humanise` rebuilt a magnitude through `Mag::new` and unwrapped the result. The only thing that constructor checks is the significant figure count, which both methods carry over from a magnitude that has already passed it, so the unwrap could never fire but stood against the house rule all the same. A private `derived` inherits the count instead, and the unwrap has nowhere left to be. The panic that could fire was next to it. `Scale::dec_exp_lookup` reached `unimplemented!` for any exponent the prefix table has no entry for, and `humanise` called it with whatever the logarithm produced: a value at 10^21 or 10^-21 took the whole process down. The lookup now returns an `Outcome`, and `humanise` leaves a magnitude beyond atto or exa unscaled rather than mislabelling or panicking. `humanise` and `normalise` also took `log10` of a signed value, so a negative magnitude got no prefix and no normalisation. Both now read the magnitude and carry the sign in the value, as `round_to_sf` does. `mul_pow10` is lifted out of `round_to_sf` and made public: it forms a positive power of ten and multiplies or divides by it, so the factor is exact as far as a power of ten can be, and splits the factor where a single power would overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:17590replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

38 operations, since the previous mark · compare with the head
+Round to significant figures by magnitude, not by a not-a-number

`round_to_sf` took `log10` of the signed value. For a negative number that is a not-a-number, whose cast to `i32` saturates to zero, so every negative value was rounded as though its leading digit sat in the units place: -0.475 to two figures came back as -0.5, -0.055 as -0.1, and -1234 to three figures was not rounded at all. A zero fared worse still, saturating the other way and overflowing the exponent. The magnitude is now taken first, the decade it names is checked against the value and nudged where the logarithm is inexact, and the scaling always forms a positive power of ten so the factor is exact over the range where it can be and is split in two where a single power would overflow. Zero, a value that is not finite, a figure count of zero, and a value too large to round up are returned unchanged rather than producing a not-a-number. The rounding rule, a tie away from zero, is now documented on both `round_to_sf` and `Quantity::rounded`, and the tests carry hand-worked decimal expectations covering both signs, values either side of one, exact powers of ten across the whole exponent range, ties, and the degenerate inputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKYf8jp6Uq7x4HHnWC4KWu

64 days agor1870400018:17551replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+List fe2o3_ore in the umbrella crate

The facade re-exports each member crate behind a feature of its own, and the newest member was missing from it. Added the dependency, the feature and the re-export, in the same shape as its neighbours.

64 days agor1870400018:17542replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Re-export InNamex where the traits that require it live

Signer, Encrypter and KeyExchanger all have InNamex as a supertrait, so every implementor must satisfy it, yet fe2o3_iop_crypto did not re-export it and each downstream crate had to name fe2o3_namex directly. Re-export InNamex along with the two identifier types an implementation of it mentions. fe2o3_ore accordingly loses its dev-dependency on fe2o3_namex.

64 days agor1870400018:17536replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Give fe2o3_data a map from disjoint intervals to values

An IntervalMap partitions the u64 line into non-overlapping half-open intervals, each carrying a value. Insertion is last-writer-wins: the new interval takes the ground it covers, splitting whatever it partly overlaps and keeping only the uncovered remainders. Adjacent intervals of equal value coalesce, so the representation is canonical and two maps describing the same covering compare equal. Checked point by point against a plain array model over two thousand random insertions and removals.

64 days agor1870400018:17530replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Put fe2o3_data's rayon behind a feature

Rayon was fe2o3_data's only third-party dependency and it was unconditional, so every consumer of the crate paid for it whether or not it ever searched a graph in parallel. Put it behind a "par" feature, on by default, and gate the two parallel searches with it. Nothing outside the crate called them. Rayon does compile for wasm32-unknown-unknown, falling back to a single-threaded pool, so it was not what stood between this crate and that target. What the gate buys there is rayon, rayon-core, three crossbeam crates and either off the tree, in exchange for a parallel search that could not have run in parallel anyway.

64 days agor1870400018:17525replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Merge branch 'ore/crate'64 days agor1870400018:17514replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

0 operations, since the previous mark · compare with the head
+Give fe2o3 an operation-based version control primitive

Ore records history as operations rather than snapshots: a whole edit is one named, appended, signed unit, so the author's intent is preserved rather than reconstructed from a diff, and who wrote it can be checked rather than trusted. The crate is a pure primitive. It does no I/O, opens no socket, touches no filesystem and reads no clock; hashing and key material reach it through the fe2o3_iop_hash and fe2o3_iop_crypto traits, so the caller chooses the algorithms. That discipline is also what makes it portable -- it builds for wasm32-unknown-unknown unchanged, letting the same history logic run in a browser and on a server. Four modules. id names an operation by replica and per-replica counter, encoded as LEB128-style varints whose decoder rejects overlong spellings, so bytes that are hashed or signed have exactly one form. op is the operation vocabulary as an enum, provisional pending the sequence structure design note, with the code space for Move reserved and left unimplemented. log is the append-only record, refusing a counter that does not advance its replica. envelope binds an operation's bytes to a public key and a detached signature, with the identifier inside what was signed so a relabelled operation will not verify. Byte payloads serialise as Dat::BU64 throughout, since a Dat::BU8 length field keeps only the low eight bits of anything longer than 255 bytes. The fe2o3_* workspace member glob already covers the new directory, so the root manifest is untouched.

64 days agor1870400018:17513replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+Cut a payload where its content changes, not at a fixed offset

CdcChunker joins the fixed-size Chunker in fe2o3_o3db_sync's CAS module. A FastCDC-style gear rolling hash, its 256-entry table generated at compile time from a pinned splitmix64 seed, picks boundaries from the bytes themselves, so inserting one byte near the front of a payload leaves every later chunk with the address it already had -- measured at 98% of chunks kept, against nothing at all for the fixed chunker on the same edit. Chunk sizes are configurable and default to 64 KiB / 256 KiB / 1 MiB, steered towards the average by normalised chunking. Manifest and Cas are untouched: both chunkers hand back the same manifest, and a store cannot tell which produced what it holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcfVQ5V2YnwJJwyVPYn5gg

64 days agor1870400018:17499replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Let a segment be crossed by a line and by another segment

Hatching a region is a scanline sweep: a family of parallel lines is crossed with every edge of the shape and the crossings sorted along each line. The line-line intersection already here answers the wrong question for that, since it reports where two infinite lines meet without saying whether the meeting lies within the edge. Segment gains cross_line, which reports the fraction along the segment at which an infinite line cuts it, and intersect_segment for the bounded pair. A crossing exactly at an endpoint counts, because dropping it opens a gap in a sweep; an edge lying along the line reports nothing, because it has no single crossing to name. Segment also gains point_at and to_line, which the first of those needs anyway. Two tests against hand-computed crossings, including the endpoint and collinear cases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCn5cdq6ntzw1D7axS5H44

64 days agor1870400018:17490replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give fe2o3_geom a triangle with barycentric coordinates

Triangle carries signed area, centroid, containment, and the pair that is the reason for the type: barycentric coordinates and their inverse. Together they carry a point from one triangle into another, which is the mapping a warp over a triangulated region is made of, and also how a value sampled at three points is interpolated between them. A collinear triple is refused at construction, since a degenerate triangle has no interior and no coordinates. Six tests against hand-computed oracles, including that the map is affine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrvETVNKwKkq28pPEMC5sA

66 days agor1870400018:17487replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Let an operator test the AI key, and set it from the app

The BYOK AI panel could be filled in but not proven: an operator typed a provider, a model and a key, and learnt whether they were right only when a real comment or a real post rode on them. A "Test the connection" button now sends a tiny fixed exchange -- none of the operator's own prompts, no post or comment -- and reports whether the model answered, echoing its words trimmed short so a wrong key, a wrong model name and no outbound connection each read as themselves. It rides in the settings form so the script reads the form's CSRF token; it posts nothing and is not a submit. The elearnity PWA drew its own Manage screens for everything but this, so an admin there could set a key only through the raw server route. The Manage tab now has an AI screen mirroring Destinations: provider, model, the write-only key, the two prompts and the alert list, with Save, Test and Clear. It reads a new /manage/ai.json (which never carries the key, only whether one is held) and posts the existing /manage/ai/save and the new /manage/ai/test.

67 days agor1870400018:17484replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Read a query parameter off the locator, not out of its daticle map

A query string is parsed with the same reader as everything else, so a bare `view=geo` lands as a `Dat::Str` while `page=2` lands as a number. Reading one back therefore takes a match rather than a get, and every caller was writing the same one: a second consumer has now appeared, so the two readers move onto `HttpLocator` itself. `query_str` returns a parameter that is a string; `query_i64` coerces from either shape, since whether a number arrives as one is the reader's business and not the caller's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GdSBK66tb3xpPqzgLp69H

68 days agor1870400018:17469replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Let a site put its own mark at the top of its blog

Every page the publish module renders opened with the blog's title linking to its index -- on the index itself, a link to the page the reader was already on. Two optional fields now decide that line: `logo`, a URL drawn as a picture, and `home`, the site's own front page. A block naming neither renders as before. Where the mark leads away from the posts, a page that is not the index carries a link back to the list beside it, the mark having been that link. The index draws no such link, being the page it would lead to.

68 days agor1870400018:17466replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Add extract_top_level_value for a JSON key shadowed by a nested one

extract_value returns the first match anywhere, which is wrong for a top-level field a nested object also carries. A Stripe event is the case: it orders the top-level "type" after "data", and the nested charge holds outcome.type "authorized", so the first "type" is the wrong one and a refund reads as an unknown event. The new function tracks brace depth (respecting strings and escapes) and matches only at depth one. Exported through the prelude beside extract_value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMUWKs4KTXnknDxjvkLdRA

69 days agor1870400018:17453replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Bring the server-rendered blog reader up to the two-column card design

The publish module's index (used by oxedyne's /readme and elearnity's server-rendered /asides fallback) was a compact list of title links; the elearnity PWA reader had since moved to a two-column layout with post cards, a search facet and a filled reading-time slider. Bring the served reader to the same design so the two agree and oxedyne gets the new look. - index() draws two columns -- the posts as cards on the left, the filter on the right -- folding to a single column with a toggle on a narrow screen. Each card is a head (date, reading time, byline where several write, title link), a fixed-height clipped preview of the formatted prose with its leading heading stripped so the title is not doubled, and a foot with a Read-more link (a plain link -- this is a multi-page site) and the post's chips. strip_leading_heading() mirrors the reader script's own rule. - filter_shell() gains a SEARCH facet (heading + Include/Only/Exclude + a search box with an inline magnifier, so no icon file must ship); the author faces start pressed; the slider carries a rail and a fill span. The facet mode labels read in the singular. about_block() tags each intro with the author's public handle. - FILTER_JS reads the cards, adds the search modes, seeds every author selected with a lone-author guard, narrows the intro boxes to the selection, paints the slider fill, lifts the fade off a preview that is not cut, and wires the mobile toggle. Vendor-neutral throughout -- the palette is the app's CSS, the structure is here. 206 lib tests green; verified headless at desktop and mobile.

69 days agor1870400018:17449replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

60 operations, since the previous mark · compare with the head
+Always show the reading-time slider, floored where posts read alike

The single-figure fallback meant a blog with one post showed text where a slider was asked for, and it read as the slider not showing at all. The control is a slider now in every case: over the posts' own range where they vary, and floored at one minute up to the longest where they read alike, so it is present and it moves, and it becomes the posts' own range the moment a second length exists.

69 days agor1870400018:17388replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Let the model gate a comment, and tell an operator when one waits

The AI arrives behind the moderation seam the author left for it, and only there. The arithmetic still runs first and unchanged: a blocked commenter is binned, a trusted one published, a shape that fails refused. The model is asked only about the comment that would otherwise sit in the queue -- a stranger's first say -- and it may publish it, bin it, or leave it waiting, which is the "auto-publish and flag" the operator chose. Its decision replaces the ordinary hold, but the security holds are applied after it and strictest-wins, so it can never carry a comment past a missing proof of work or a trusted address arriving from a new place. A model that is not set up, or will not answer, changes nothing: the comment waits, as it would have without any AI at all -- a network failure is never read as a decision. Only the comment's text is sent; no name, no address. The reply is read towards safety: the three words map to the three verdicts, a reply that says nothing recognisable holds rather than guesses publish, and a reply torn between spam and approve is read as spam. That parsing is a pure function, tested; the call around it is thin. And when a comment ends up waiting, the operators who put their address on the AI page are told -- one plain line, from the site, carrying none of the comment's text, since what is waiting is unmoderated and forwarding it would do the spammer's delivery. The send is spawned, not awaited: it is the operator's business, and the reader who posted should not wait on an SMTP round-trip to be told their comment is held. 202 lib tests.

69 days agor1870400018:17377replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+Add the Fix button: propose a copy-edit, the author accepts

The editor's Fix button sends the post and the site's fix prompt to the model and shows what it would change -- as a line diff against the author's own text, removals struck through and additions highlighted -- so a copy-edit is a change to see rather than a wall of text to re-read. Nothing changes until "Use this" is pressed; "Discard" leaves it be. This is the propose-then-accept the author asked for, and the reason: these blogs are written by hand, so the model suggests and the person decides. Both composers do it identically -- the server-rendered console and the app's own -- so a fix reads the same wherever it is offered. The server endpoint `/manage/ai/fix` holds the key and the outbound connection: it loads the settings, calls `fe2o3_net::llm::complete` with the fix prompt, and returns the suggestion as JSON, changing nothing. Every way it can fail -- no AI set up, no outbound TLS, a model that will not answer -- comes back as a plain reason the editor shows, never a broken page, and a model's own error is logged rather than shown, since it can carry a key or a quota figure that does not belong on a page. The diff is over lines, cheap for a post and enough to see an edit; a very long post shows the suggestion plainly rather than build a table nobody waits for.

69 days agor1870400018:17360replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Add the AI settings panel and encrypted key storage

The management side of BYOK: a new AI panel in the console, modelled to the byte on Destinations. It holds the provider and model, the API key, the two prompts, and the addresses to email when a comment is held. The key is stored exactly as a Mastodon token is -- a Dat under a `publish/ai` key in the vhost's own database, encrypted at rest under the database's scheme, never in a file in the clear and never logged. It is write-only from the console: a stored key comes back as the word "kept", never as its value, and a blank field keeps what is held, so the model or a prompt can be changed without re-typing it. Clearing the key is a separate, confirmed action that turns AI off. The two prompts prefill with conservative defaults -- the fix prompt is told to correct only what is plainly wrong and leave the voice alone, because the blog is hand-written; the comment prompt asks for one word, APPROVE, SPAM or HOLD, mapping to the moderator verdicts increment 4 will add. A blank prompt box means "use the default" at call time, so clearing it restores the default rather than sending nothing. The alert addresses take a lenient list and keep only the valid, since an alert to a bad address is an alert lost. The settings live in a new `publish::ai` module (AiSettings + get/put, the same shape as `send`'s creds), and the form is a pure function so it is tested and rendered without a database behind it. `fe2o3_net::llm` supplies the provider and the config. 201 lib tests.

69 days agor1870400018:17348replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Add a bring-your-own-key LLM client to fe2o3_net

The first piece of AI on the publish side: a single-turn completion against an OpenAI-compatible chat API. A system instruction and a piece of text go up, an assistant message comes back -- the shape a "fix this post" button and a "judge this comment" moderator both need, and a narrower client is a smaller thing to get wrong. One client covers OpenRouter, Fireworks and Mistral, because all three speak the same dialect: a POST of {model, messages} to a chat/completions path, bearer-authenticated, answered with choices[0].message.content. So a provider is a host and a path and nothing more, and a fourth that speaks it is a line in one enum. A provider that spoke a different dialect would be a second `complete`, not a second arm; this does not pretend otherwise. Built pure, wrapped thin, like the Mastodon/Bluesky sender it borrows its network seam from: `chat_body` builds the request and `chat_reply` reads the answer -- both pure over strings, tested without a socket, since a test cannot reach a live model with a key it does not have. An empty `choices` is surfaced as "the model said nothing", and a provider's error reply as its own message, rather than either becoming a silent empty string. The key is never logged.

69 days agor1870400018:17332replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Make the index a list of choices, not the posts themselves

The stream inlined each post's whole prose beneath the search box, which made a reader scroll past the very thing the search was there to skip, and put the same words on the page twice -- once here and once at the post's own URL, the canonical one a link points at. An index is a set of choices, so a post is now its title, the date and reading time a reader weighs before opening it, and its chips; the prose lives one click away, where it can be read whole and linked to. The app's stream and the server's index draw the same item, to the class. The reading-time control is present whether or not it can filter: a dual-thumb slider where the posts span a range, and the single figure where they read alike, so the row never becomes a slider that can only sit still -- which is what hid it whenever a blog had one post.

69 days agor1870400018:17328replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Give fe2o3_graphics an SVG writer and WCAG colour checks

A path-data writer that mirrors the existing reader exactly (round-trips through it), plus the presentation attributes stroke and colour already model. WCAG relative luminance and contrast ratio beside Rgba (black on white is 21:1), and dichromacy simulation. 13 new tests. No element tree or document structure -- that stays the caller's format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T73H8LyjhVhYd4S8SUfBCY

69 days agor1870400018:17317replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+Give fe2o3_units a dimension-checked quantity layer

A Dimension of rational exponents over the seven SI base dimensions plus a tagged plane angle; a Quantity carrying magnitude, significant figures and dimension whose multiply and divide combine both and whose add and subtract require equal dimensions, erroring on a mismatch. Sig-fig propagation and the common non-SI teaching units. 19 oracle tests. Also fixes two pre-existing broken doctests and a missing test import that blocked the crate's test build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T73H8LyjhVhYd4S8SUfBCY

69 days agor1870400018:17303replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Give fe2o3_geom a floating-point planar module

Point, vector, line, ray, segment, circle, arc and angle as f64 types, with the predicates a constraint and a back-solve both call: incidence, line-line, line-circle and circle-circle intersection, foot of perpendicular, projection onto line and segment, and angle between two rays. 22 tests against hand-computed oracles. The integer UI-layout module is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T73H8LyjhVhYd4S8SUfBCY

69 days agor1870400018:17292replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Autosave the server-rendered composer too

The app's composer lost its Save button to autosave; the server-rendered one, which oxedyne writes with, kept it. Now it writes itself the same way -- a draft persists as it is typed, since a draft save reaches nobody, and publishing stays the one deliberate save that can. Where Save stood there is now the same line: Saved N [unit] ago, and Published when it is live. Static like its sibling scripts: the CSRF token rides the form's own hidden field and the save URL is the form's action, so nothing is interpolated. The save asks for JSON, which the handler already answers for a fetch caller; a pagehide beacon flushes a pending edit whichever way the author leaves, so the last thing typed is never lost to the gap before the next scheduled save. The category and tag scripts now announce their hidden input's change, so moving a chip schedules a save like any edit.

69 days agor1870400018:17288replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+One chip geometry everywhere, and a closer that is actually centred

The filter's chips stood much taller than a post's own, and read as a different component; they are the post chip's size now -- same font, same pill, height set to match -- so a chip is one thing wherever it appears. The closer was a pixel low and a pixel left, every time. It was sized to the chip's full height and positioned against the padding box, which the 1px border insets -- so the maths that should have put the cross on the centre of the pill's right cap missed by the width of the border on both axes. Sizing the closer to the padding box (`height:100%`) instead makes half its height in from the padding edge equal half the full height in from the border edge, which is the cap centre exactly -- centred by construction rather than by a number that nearly worked. The console's chips take the same treatment, so the composer matches the reader. The cross itself is two drawn bars, not the U+00D7 glyph, which the site's display face renders as a heavy asterisk. Their width is taken off the chip height, not `em`, because the closer carries `font-size:0` to hide the glyph and an `em` there would collapse to nothing.

70 days agor1870400018:17281replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Make a category a chip, the same as a tag is

Categories were a row of checkboxes sitting beside a two-box chip widget doing the same job for tags, and read as two unrelated controls. They are now one widget instantiated twice: a Selected box and an Available box, chips moved by a click or a drag, and Includes/Only/Excludes over each -- so a reader learns the control once. The default (everything selected, Includes) behaves exactly as all-checked did, so a reader who touches nothing sees no change. One rule set governs both families. A post with no values in a family always passes it; an empty Selected box imposes nothing; and a value the filter never offered can never hide a post -- that last is why the post's values are intersected with the offered vocabulary before the mode is applied, without which `Only` would silently drop every post still wearing a category the site has since retired. `?cat=` deep-links as `?tag=` always has. A chip may only be dropped in a box of its own family. The composer follows, in both consoles, with one deliberate difference: no search-or-create line, because the category vocabulary is fixed by config and cannot be minted from a post. Two things found by rendering rather than by reading, both invisible to every assertion in the suite: The console took its background from `--bg-primary` falling through to `--body-bg`, and its foreground from `--text-primary` falling through to `--body-color`, independently. On a site where those two name the same colour -- because its body text never sits on its body background -- the entire console rendered in its own background colour and every word of it vanished. The pair is now read as a pair, with literals that are known to contrast as the only fallback. And the composer's "Writing as" fell back to the username where a member had set no profile. A username is the SHA-256 of a passphrase, and the page an admin opens to edit somebody else's post is read by someone who is not its owner. It says `Anonymous` now, and a test holds that the username reaches nothing drawn. 195 tests.

71 days agor1870400018:17275replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

78 operations, since the previous mark · compare with the head
+Show a post's category, and render the console before believing it

Categories were stored, filtered on and ticked in the composer, and never shown to a reader anywhere. A post now leads its chip row with them -- `.post-cat`, solid, ahead of the outlined tags -- because the two are different kinds of thing: a category is the fixed vocabulary the site chose and says where a piece sits in the whole; a tag is whatever the author reached for. Drawn alike they are one undifferentiated row and the distinction is invisible. The chip links to the index narrowed to that category, which the filter script now honours as `?cat=`, the promise a tag chip has always made. A category may hold a space or a capital, so it is percent-encoded on the way into the query. And the reason a shouted, jammed row of category checkboxes shipped: no test in this module has ever rendered a page. They call a fragment builder and match a substring of its markup, so every one of them passes with the styling broken -- and the console is the one surface a browser cannot be pointed at without a passphrase, so nobody looked either. `ui_dump` closes that: `STEEL_UI_DUMP=<dir> STEEL_UI_CSS=<dir>` writes each console screen out as a whole page, linked to the site's real stylesheets, to be opened and looked at. Unset, it writes nothing. The first thing it found was the bug in hand. `.mc-form label` is (0,1,1) and `.mc-cat` is (0,1,0), so every category name was shouted in uppercase like a field's name, and the label stayed `display:block` -- which is not a flex container, which silently dropped the `gap`, which jammed each word against its own checkbox. Named through `.mc-form` now, and the tick takes the site's accent instead of the browser's default.

71 days agor1870400018:17196replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+Date a post the author gave no date, and answer a HEAD

Three things a published post ran into on its first day. A post could be saved with the date field empty, and the feed then had nothing to put in the `updated` every Atom entry must carry, so it emitted the epoch -- filing the piece under January 1970 in every reader that took it, silently. The composer now offers today in the field (an ordinary field; the author may type over it), and a save arriving with it cleared is dated today rather than not at all. `publish::today` is the one definition, and a test holds that it is a date the store will take. Steel answered no HEAD request at all. The method reached no branch of the dispatch, no response was ever built, and the caller sat until the read timed out -- so `curl -I`, and every uptime monitor that speaks HEAD first, saw a 408 from a server that was perfectly well. A HEAD now runs the GET and withholds the body at the wire, which is the only honest way to produce the fields RFC 9110 9.3.2 says must match the GET's; `Content-Length` still counts the body that was built, since a caller asking how big a thing is deserves the true answer. The read tally learns to ignore it, or the site's most devoted reader would be its monitor. 189 steel tests, 143 net tests.

71 days agor1870400018:17182replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+Say it of the console's own pages too

The sweep missed the responses built outside `page()`: the sign-in page, the not-yet-an-admin page, and every small JSON answer -- including `/manage/status`, which carries the CSRF token an app writes with. A held sign-in page is shown to somebody who has since signed in, and a held CSRF token fails every write that uses it.

71 days agor1870400018:17158replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+Say how long a generated page may be kept, which is not at all

A response the server builds describes the site at the instant it was asked for, and the next thing an author writes changes it. Every one of them went out with no cache directive and no validator, which does not mean "do not keep this" -- RFC 9111 4.2.2 lets a store invent a freshness lifetime for a response that says nothing, and a browser duly redraws the index from a copy taken before the post existed. Publishing then appeared to do nothing until the author forced a refresh, which is a thing an author might think to try and a reader never would. The publish pages, the index JSON, the Atom feed, and the console's own pages, JSON and CSV export now say `no-cache`: a store may keep them and may never use one without asking first. The static path already said this and is untouched, as are the two responses that mean their `max-age` -- the comment and filter scripts, and an uploaded picture. `Cache-Control` is a list field, so a second directive would join the first rather than replace it; the default therefore defers to a response that has already said what it wants, and a test holds that line.

71 days agor1870400018:17136replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+Serve an uploaded picture with nothing it may execute

An SVG is a document. Served from the site's own origin it may carry script that runs as the site, and the profile picture accepts SVG -- which is exactly the format a traced portrait arrives in, so this is the likely upload rather than the exotic one. Drawn in an `<img>` no browser runs it, but the URL can be opened directly, and an admin uploading an SVG they were given is not thereby vouching for its contents. So the avatar response now carries `default-src 'none'; style-src 'unsafe-inline'; sandbox` and `nosniff`. The headers are unconditional rather than applied to SVG alone: they cost a PNG nothing, and a rule that applies sometimes is a rule that will one day be missed. fe2o3_net gains `DataUrl::is_raster_image` for a caller that would rather not think about serving a document it was handed, and `is_web_image` now says in its own documentation what including SVG commits the caller to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cy863cEKtx5s4xDvK8REPk

71 days agor1870400018:17114replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Let every site admin curate the shared tag vocabulary

Deleting a tag reaches into other authors' posts, and that act was held to a *pinned* admin -- one the operator named in the vhost config -- where an admin the site had granted from the browser could not. Two ranks, differing in one act. A site admin is already trusted with the site's prose, its subscribers and its comments; a second rank governing one destructive act is a rank nobody can keep straight, and it could only be granted over ssh. So the distinction goes, and the guard moves to where it does some good: the composer now says how far the tag reaches -- how many posts, by how many authors -- and says it before it asks, in both consoles. The counts come from store::tag_counts, one pass over the records for the whole vocabulary rather than one pass per tag, and ride out on /manage/tags.json for an app that draws its own palette. Authorship is untouched: every author adds tags and edits their own post's tags, admin or not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cy863cEKtx5s4xDvK8REPk

71 days agor1870400018:17109replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+Keep a member's login username off every page

A member's username is the SHA-256 of their passphrase. It was reaching readers: as the `data-author` on every post in the index, as the author key in the posts JSON, and -- for a member who had never opened their profile -- as the display name itself, since the name fell back to the username. Anyone reading the page got a verifier to test passphrase guesses against, offline and unwatched. A profile now carries a handle: sixteen random characters minted the first time the member saves, derived from nothing. That is what a page draws, what a post's author field says, and what an uploaded picture is keyed and served by. An author with no profile yet takes a handle made from their position in the page (author-1, author-2), which distinguishes them within one rendering and means nothing outside it -- deliberately not a hash or a prefix of the username, either of which a guess could still be tested against. An unnamed member reads as Anonymous. The username stays server-side, where it pairs an author with the posts that name them, and the type says so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cy863cEKtx5s4xDvK8REPk

71 days agor1870400018:17076replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

64 operations, since the previous mark · compare with the head
+Give an author a page of their own, and the blog a description

A blog more than one person writes had no way to say who anyone was: a post named its author by a login username, which is the hash of a passphrase, and a reader arriving at a list of titles was told nothing about what the place is for. An author's profile now holds a description beside the name and picture, and the profile page takes a picture as a file rather than as an address somebody has to host elsewhere. The browser hands a chosen file over as a data URL, which fe2o3_net now reads (http::data_url) -- a small upload without a multipart parser on either side. The bytes are kept in the site's own database and served from the module's own path, under the media type they were stored with, and only the image types a browser draws are ever stored: bytes served under a type the sender chose freely are how a picture becomes a page. The description is then met wherever it answers a reader's question: above the posts on the index, and under a post beside a byline. Where one person writes the blog, their description is the blog's, so it carries no name over it and there is no second place for the two to disagree. The index resolves whoever may write here as well as whoever has, so a blog whose first post is not written yet still says what it will be about -- while the filter offers a face only for an author with posts in the list, a control that could otherwise only disappoint. Also: /manage/profile.json, for an app that draws the form itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cy863cEKtx5s4xDvK8REPk

71 days agor1870400018:17011replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

79 operations, since the previous mark · compare with the head
+Hand the faces and the taxonomy to a page that filters for itself

The posts JSON was enough for a page that draws a stream and no more. A page that draws its own filter needs two things it cannot work out from the posts: who the authors are, since a post names its author by a login username which is a hash and shows a reader nothing, and which categories the site offers, since the posts carry only the ones they wear and a checkbox list built from those would be narrower than the composer's. So index.json now carries an authors list -- username, name, avatar and the initial to draw where there is no picture -- and the site's whole category vocabulary in config order. The authors are resolved for the JSON on the same terms as for the index page, and for nothing else: a post view, the feed and the filter script want none of it, and a read per author on each of those is work nobody asked for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cy863cEKtx5s4xDvK8REPk

71 days agor1870400018:16931replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Carry the category taxonomy in the console status endpoint

A PWA composer draws its own form and cannot read the server-rendered one, so it needs the site's categories over JSON to offer a checkbox each. /manage/status already carries the destinations an admin may post to; give it the categories too, escaped for a JSON string since a category is a free config value where a destination is a fixed word. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcivBLAdgWAYd2iM5W61to

71 days agor1870400018:16923replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Give the publish module authors, categories and a reader filter

Replace the thin Note/Essay PostKind with a config-defined category taxonomy, attribute each post to a site member, and put a filter above the index the reader drives in their browser. - Post/Record carry author (a member username) and categories, both on the empty-list idiom so every existing record still reads; PostKind is removed. PublishConfig gains categories (a default thematic set) and default_author, both optional. A category name may not hold a comma, the character that joins them in the field and the filter. - Member profiles (name + avatar) live at publish/profile/<username>, since a login username is an opaque hash; resolved to an Author for a byline and the filter's author row, edited at /manage/profile. - The index renders every post with its facts as data attributes and a filter that shows and hides them: search, author faces, an Includes/Only/Excludes tag mode over two chip boxes, category checkboxes and a reading-time slider. Progressive enhancement -- the whole list stands without the served script. Reading time is one definition (read_mins), shared by the badge and the slider. - The composer gains the two-box tag UI with a search-or-create line, category checkboxes and an author note. A curator -- a member in the operator-pinned site_admins -- may delete a tag across every author's posts behind a confirmation naming the cost; an ordinary author can only add a tag or take one off the post in hand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcivBLAdgWAYd2iM5W61to

71 days agor1870400018:16915replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

132 operations, since the previous mark · compare with the head
+Let an O3db hold the chunks a caller has already got

The content-addressed store needs somewhere to keep chunks. A gateway that already runs a local Ozone should not stand up a second storage system for them, so this backs the Cas trait onto O3db, the same way O3dbStorage backs the distributed-Ozone storage trait. One chunk is one key/value pair: the key is "chunk:{hex_addr}" so a prefix scan enumerates the store for garbage collection, and the value is the opaque chunk bytes -- ciphertext, once the caller has encrypted. A delete is a tombstone through the ordinary store path, so a following get sees it on the first read rather than racing a direct-to-disk delete, and the log-structured engine reclaims the space on compaction. The put path still refuses a chunk whose bytes do not hash to its address, so the guarantee holds whichever store is behind the trait. Generic over the six O3db type parameters, like its sibling; the key round trip is unit-tested, and the live put/get path waits on a running engine as O3dbStorage's does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQu7dkjvK7kPhvSZ2QDsYS

71 days agor1870400018:16782replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Break a payload into chunks, and name each by its content

A synced payload shipped whole forces every device to hold the whole thing: the ceiling is the smallest device, and every sync re-uploads everything. The way out is to stop shipping it whole. This adds the substrate for that -- a content-addressed store in fe2o3_o3db_sync, sitting beside the local engine it will be backed by. A Chunker splits bytes into fixed-size pieces; each piece is addressed by the SHA-256 of its bytes, and an ordered Manifest of those addresses reconstructs the whole. A store keyed by address holds a chunk once however many manifests name it, so a device fetches only what it lacks and a large corpus becomes usable from a device too small to hold it. SHA-256 and not SHA-3 on purpose: the caller is a browser that computes addresses through Web Crypto, which offers SHA-256 but no SHA-3, and a gateway that must compute the same address to verify a chunk before accepting it. The one hash both sides can produce identically wins. This is why fe2o3_hash::sha256 exists at all; the distributed-Ozone digest hash has a different job and is left alone. Integrity is enforced where it is cheap to enforce. A store refuses a chunk whose bytes do not hash to its claimed address, so a client cannot mislabel one; reassembly re-hashes every fetched chunk against the manifest and checks the total length, so a corrupted or substituted chunk is rejected rather than returned. Encryption stays the caller's concern: encrypt each chunk first and the address is over ciphertext, the store never sees plaintext, and dedup is within one keyspace by construction. Garbage collection is mark-and-sweep against a live set the caller supplies -- the addresses reachable from the manifests it still holds -- so a lapse can free unreferenced overflow without disturbing a chunk any live manifest needs. Fixed-size for now; a content-defined chunker that localises an edit's churn can replace it without touching Manifest or the Cas trait. Nine tests: address determinism, hex round trip, chunk-and-reassemble across empty/short/exact/ remainder, dedup, manifest Dat round trip, tamper rejection, mislabel rejection, and a sweep that frees exactly the unreferenced set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQu7dkjvK7kPhvSZ2QDsYS

71 days agor1870400018:16778replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say null in JSON, where JDAT says none

The JSON encoder wrote an absent option as the quoted keyword "none". JSON has no such keyword, and a reader handed that string sees a present value whose content happens to be a word -- truthy in JavaScript, so an `if (obj.field)` guard takes the branch for a field that is not there. EncoderConfig gains `none_as_null`, set by the json presets and off everywhere else, so JDAT output is untouched: `none` is JDAT's own keyword and round-trips through its decoder. It applies to Opt alone; a user kind carrying no value still encodes as its label, which names the kind and is a different statement from "there is nothing here". Found by a downstream file that a browser reads: the cleared field drew an empty row rather than none at all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HeSWtQeecb8HHTUUe1GQm4

72 days agor1870400018:16774replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Let a commenter correct themselves, and notice an impersonation

Two things. A comment claiming a commenter the site trusts, arriving from somewhere that commenter has never used, is held and says so. Usually innocent -- people travel -- but it is also what impersonating a regular looks like, and it is the one case where a familiar name in the queue is worth a second look. This is the cheap half of what a confirmed address would have bought, and it works on a site that cannot send mail at all. And a commenter may correct what they just wrote, for fifteen minutes, holding a token handed back once in a cookie that expires with the window. An edit to a comment still waiting changes it in place, since nobody has seen it. An edit to a comment already published sends it back to the queue -- otherwise the window is a bait-and-switch: write something agreeable, be approved, then change it to whatever you liked with the site's endorsement already attached. The window is read from the comment's own stamp with a strict parser rather than CalClock::parse_iso, which delegates to a general datetime parser lenient enough to read "not a time at all" as a time. A permissive read there would hand an unbounded edit window to any comment whose stamp would not parse. Caught by the test, which is also why the stamp reader has its own: its expected values come from `date -u`, and the first draft of it was three days out because I wrote the number from memory. The code was right and the test was wrong, which is the failure mode that argument is supposed to catch. Four rig checks, including that somebody else's token changes nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

72 days agor1870400018:16768replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

41 operations, since the previous mark · compare with the head
+Make the name registry loadable, without moving a single id

namex.jdat has never loaded. Not since a regression -- since the day it was written: the file's 63 entity keys were tagged (b32|"..."), which conscripts jdat's HEMATITE64 decoder, and that alphabet deliberately has no '/'. Meanwhile NamexId's own Display, its TryFrom<&str>, its generator, and every caller in the workspace all speak standard base64. The ids were right and the tag was a mislabel, so the tag is gone and the ids are untouched -- 63 before, 63 after, identical, checked against the committed file rather than asserted. Fixing the alphabet only uncovered the next fault, and the file needed four before it would load: backslash-newline continuations inside strings, which jdat never supported; four unterminated desc strings, one of which was swallowing the rest of the file; and use_ordmaps left false where load requires ordered maps. Each was hidden behind the one before it, which is why this went unnoticed for so long -- there was never a partial success to be curious about. The round trip is a fixed point: export, feed the export back, byte-identical. Two things left alone and worth knowing. jdat's encoder does not escape a quote inside a string, so a description containing "RSA" cannot survive a round trip; that is a real encoder gap and jdat was out of scope, so the file uses typographic quotes and the gap remains. And the test writes namex_echo.jdat into the tree, which is now ignored rather than committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

72 days agor1870400018:16726replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

180 operations, since the previous mark · compare with the head
+Let the site open and close its own comments, and give a reader the rest

Comments were open or shut by a config file and a restart, which is the wrong place for a decision somebody makes when a thread turns bad at eleven at night. The site keeps its own switch now, in its own database, and the console and the app tab both carry it; the config's `comments` is where a site starts rather than where it stands. Closing stops new comments and does **not** hide the ones already published. A conversation that happened still happened, and taking it off the page would be a deletion nobody asked for. The rig caught that: I had it hiding everything. Four things a reader expects and did not have. A preview, rendered by the same parser and the same policy the page uses -- there is no second parser here, and a preview that rendered differently from the post would be worse than none. Paging, counted in threads rather than comments so an answer never lands on a different page from its question. An ordering, as two links rather than a control, so it works with nothing running and the view can be linked to. And a separator between those two links, because unstyled they read as one run-together word: this module leaves the look to the site, but it should not leave it illegible. The Namex registry named the wrong algorithm: the id `Vybb…` is registered as SHA_256 and the code has always used it for SHA3-256, with no SHA3 entry at all. The code was right and the registry was wrong, so the entry is renamed rather than the code re-pointed -- no orphaned id, no change to what anything reports. Safe because these ids are never persisted, which I checked before touching it. Note for the next person: `namex.jdat` does not currently load at all (`Character '/' not recognised by this Base2x alphabet`, on a pre-existing id). That is not from this change and is not fixed here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16545replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

35 operations, since the previous mark · compare with the head
+Let a site run a policy, and stop one sender flooding it

The form's script was an inline block, so any site carrying it had to allow inline scripts to run a Content-Security-Policy at all -- switching off the layer that would contain a mistake in the render policy, on a page built from strangers' prose, whose same output is rendered into the admin console. It is a file now, cached for a day, and the post page carries no inline script. And nothing bounded how fast one sender could comment. The per-post ceilings bound storage; they do not stop a sender filling them. A sender is now held to an interval and an hourly count, keyed on the salted address hash rather than on the address they typed -- the one durable signal about them, which was being collected and ignored. Both bounds are configuration and not constants, which the rig proved by failing: every comment it sends comes from one address, and so does every comment from a household, an office or a university. A site whose readers share an address wants these low or off; one being flooded wants them high. Zero means off, and the counts are read at whatever width the grammar typed them -- a bare `0` is not a u64 to the decoder, so a match on one variant would have refused exactly the value an operator is most likely to write. That also failed first in the rig. The browser still solves its proof in under a second with the script served from a file, checked by driving one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16509replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

16 operations, since the previous mark · compare with the head
+Draw the queue where the work is done, and page it

Three of the review's remaining findings, and one thing I had simply not finished. The app's Manage tab had no comments at all. I built the JSON endpoint for it last night and never built the view, which is the same mistake as linking out to a server page: a surface the tab does not draw is a surface the tab does not have. It draws one now, in the app's own idiom, with the waiting count on the button that opens it -- that count being the only thing on the screen that needs somebody, and it should not have to be asked for. The queue is paged. Not as a nicety: every card renders its comment's Markdown, so a page of all of them is the most expensive page on the site, and it is the page an operator opens *because* something has flooded the queue. The recovery path must not be the thing that fails first. And a commenter may call themselves anything, including the name of the person whose site it is. Nothing can stop them typing it, so the site marks what it wrote instead -- a flag the console sets and no submission can carry, since there is no field on a submission that reaches it. An absent mark is the claim, not the name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16492replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Verify the proof with the digest the browser actually computes

An adversarial review of yesterday's comments found this, and it is the worst kind of bug: the server hashed SHA3-256 while the form's script hashed SHA-256, so no proof ever verified. A refusal returns before the comment is stored, and a refusal is deliberately indistinguishable from a hold to the sender -- so every comment from a reader with scripting was destroyed, and each of those readers was thanked for it. Nobody would ever have reported it. The test that should have caught it hashed SHA3 on both sides of its own assertion. It agreed with itself and proved nothing. The replacement checks a nonce solved by python's hashlib, and a real browser now posts a comment through the real form in the rig's own driving. Five more from the same review: A challenge was a pure function of the post and a permanent secret, so one solve served every future comment on it -- a tax paid once, not once per comment. It now names the hour it was issued in, and a verifier takes that hour or the one before, so a reader who opened the form at 10:59 still posts at 11:01. `parent` was the one field with no bound at all: it went from the form into the stored record unchecked, so a caller could write megabytes of their choosing per request. It is an id this module minted or it is nothing. It also reached an attribute unescaped, which nothing exploited only because the sole caller passes None -- which is exactly why it was missed. Trust attached to an address somebody typed, and nothing proves they own one. An attacker who knew an approved commenter's address inherited their approval and published at will. Trust is now honoured only where the sender matches the one it was granted to. That is weaker than a confirmed address and it is written down as such rather than hidden. A protocol-relative destination has no colon, so `//evil.example/x` was read as "relative" and passed an allowlist meant to keep links on the site. And a published comment lent the site's standing to whatever it pointed at, which is the entire economic motive for comment spam. A stranger's links carry `nofollow ugc noopener`; an author's still carry none, because a link an author chose is one the site vouches for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16482replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

75 operations, since the previous mark · compare with the head
+Do not give every site a public write endpoint it did not ask for

Three faults in what shipped an hour ago, found by pointing the same probe at production that the rig should have carried from the start. Comments were on for any site with a publish block and a database. Nobody chose that; it followed from there being no way to say no. They are now off unless a site sets `comments: true`, and the field defaults, so no existing config breaks. A comment named its post and nothing checked the post existed. POST to /readme/anything-at-all/comment answered 303 and wrote a record: an unauthenticated write to storage keyed on a string the sender chose, which is a way to fill a disk rather than a way to comment. Measured against the live site, not imagined. Both routes now require a slug that names a post a reader can see. And a held comment is storage somebody else chose to spend, with no ceiling on it. A post now stops taking comments once fifty are waiting -- a visible, recoverable state that a person clears, unlike a disk that filled overnight. Approved comments are not counted, since those are storage the site's own admin chose to spend. Two rig checks for the second fault, which is the one that was exploitable, and the rig's own vhost now opts in like any other site would. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16406replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Take comments, and let a person decide what appears

The pipeline end to end: a form under the post's own path, a proof the browser does on submit, a honeypot, arithmetic moderation, a queue, and a reader's view that shows only what somebody approved. SHA-256 in fe2o3_hash, because the proof has to be computed by a browser and verified by the server, and there was no hash both could do -- SubtleCrypto has no SHA3 and this workspace had no SHA-256. FIPS 180-4, no new crate, and its vectors checked against sha256sum rather than against itself. The rules that matter are the ones about failing safe. A comment with no proof is held, not refused: a reader with no scripting is still a reader, and the proof buys a queue that is not full of machines rather than a condition of being heard. A refusal reads to the sender exactly like a hold, because anything else is an oracle -- something told "your proof was wrong" retries with a better one. The honeypot is hidden by an inline style and not a class, because this module does not own the site's stylesheet, and a honeypot a reader can see is a field they will fill in and be silently refused for. That last one was found by looking. With only a class it rendered as an ordinary visible input, and the markup gave no sign of it. Twelve rig checks drive it from outside, which is the only place most of this can be proved: that an unapproved comment is not in the page a reader gets, that a comment which filled the honeypot was never stored, that a proof for a challenge the site never set is refused, and that an approved comment appears with its script destination, its script tag and its tracking image gone and its words intact. Driven in a browser too: three comments with one nested, the honeypot off screen at left:-9999, and no address anywhere in the markup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16394replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

47 operations, since the previous mark · compare with the head
+Make a stranger's prose safe to publish, and give comments their shape

Two pieces, and the first is the one the second rests on. A document from an untrusted author now passes through a policy before it is rendered. Not a sanitiser: this tree cannot carry HTML at all -- there is no Block::Html and no Inline::Html, and the writer escapes every run of text -- so there is no markup to strip. What there is instead is a handful of places where a string the author wrote reaches somewhere with meaning, and those are what the policy governs: a link's scheme, because escape_attr stops a destination breaking out of its quotes and stops nothing about a javascript: destination being a script; an image's source, because a remote image is a request the reader's browser makes to a third party and a commenter who can place one can log every reader of the thread; and attributes, because an id or a class a stranger chose lands in the site's own document. The renderer's own module documentation has named the first of these as an open hole since it was written. Nothing is rejected wholesale. A refused link keeps its words and loses its destination, a refused image becomes its alt text, a heading is demoted rather than dropped. A comment that says something worth reading and one disallowed thing should lose the one thing. The second piece is what a comment is, where it is kept, and what decides whether it appears. Three seams, two of them deliberately unfilled: Identity has an arm for a network-vouched identity that nothing issues yet; Moderator has one arm, arithmetic, and an AI moderator is the same seam with another; Ranker orders a level and could weigh something later. The pipeline is written once and each of those arrives without touching it. The rules that matter are in the model rather than the caller. An unknown state reads as pending, never approved -- a record a later version wrote must not publish itself by being unreadable. A bare name is not an identity, or a stranger could inherit somebody else's approval by typing their name. Trust is consulted last, so it cannot carry a comment past a rule that would otherwise catch it. The strictest verdict wins and nothing later can loosen an earlier refusal, which is what will keep a persuadable model from overturning a proof that was never done. Proof-of-work is bound to its challenge, so a proof for one post is not a proof for another. A commenter's address is stored one-way and salted; it recognises a returning nuisance and reconstructs nobody. 29 tests. The depth test earned its place immediately: the first threading implementation flattened only two levels at the floor and silently lost five comments of eight in a deep chain. The rule bounds how deeply a thread is drawn, never how much of it is kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16346replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Keep the dashboard out of the index

The site console tells a crawler not to index it and the operator dashboard did not, so a search for a site's own domain could return its admin login. Nothing behind that page is readable without a session, but it advertises where the dashboard is and what it runs, which is free reconnaissance and an odd thing to find under your own name. Both shells now carry it: the authenticated pages and the login card. The newsletter's HTML body deliberately does not -- it is an email, and no crawler will ever see it. Found by looking at what the live sites actually serve, after oxedyne.com and daimond.oxedyne.com began appearing in search results. Five rig checks, one of which is that the posts stay findable: they exist to be found, and a blanket rule that quietly covered them would be the opposite mistake. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16339replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Serve the subscribers and the reports as data, not only as pages

The app's Manage tab draws its own surfaces from JSON -- the post list, an editor, the destinations panel -- and had nothing to draw the subscribers or the reports from. So the first attempt at giving Elearnity those two linked out to the server's own pages in a new tab, which is not the tab format at all: it leaves the app to look at somebody else's screen. Two endpoints, shaped as the pages are: subscribers.json carries the list and its counts, reports.json the three halves the reports page renders. Both sit behind the same gate as the pages they mirror. The ceilings the pages state in prose are deliberately not carried in the data. They are properties of the figures that whichever surface draws them must state, and a caller that omits them is showing numbers without their caveats. Both surfaces in this tree state them. Eight rig checks, including that neither endpoint answers an anonymous caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16335replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Count what was read, and nothing about who read it

The last unbuilt piece of the reporting. A per-post tally in the vhost's own database, incremented where a post is served, and shown on the reports page ranked most-read first. What is not counted is the substance of it. A request carrying a management session is the author, and a count that climbs while its author re-reads their own draft measures attention rather than readership -- worse than no count, because it looks like one. A request from an obvious machine is not a read either, and a browse of the index is not a read of everything listed on it. No identifier is derived, stored or compared, so this is a tally of readings and never of readers, and the page says so where a per-reader figure would otherwise be looked for. The tally is kept at the call site because that is the last place holding the database: the renderers take a slice of posts on purpose, so `served_post` is the pure half of the decision and the counting is the caller's. A tally that cannot be written costs the tally and never the page. Read-add-write, not an atomic increment, because the store offers none: two reads landing together can lose one. Accepted deliberately -- this counts roughly how many people read a post, which is not a question improved by a lock across every request's render path. `reads_all` selects keys by scan and fetches values by get, which is not an optimisation to undo. Scan v1 answers Dat::Empty for every value whatever include_values asks of it, and says so in a log line rather than an error, so asking it for values yields a tally of nothing, silently. Caught by the real-database test, which is the only thing that could have caught it. Seven rig checks prove the exclusions from outside: ten fetches by a machine count for nothing, the author reading their own post counts for nothing, a browser with no session is counted, and the index and the feed are not reads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16330replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Set a site's destinations from the console it already has

The destinations a post can be sent on to could be set from the app's Manage tab and nowhere else, so a site served by the console alone had the credentials endpoints and no page to reach them by: the two front ends over one API had each grown half the feature set. This adds the server-rendered half. Every secret stays write-only, exactly as it is over JSON -- a stored secret comes back as the word that one is held, never as its value, and a blank field keeps what is stored so a handle can be corrected without re-typing a password. A remote the config file provides is named as such, so a site keyed from {env:} or {file:} is not told its destination is unset. Clearing is a second form rather than a checkbox in the first, so a save cannot clear by accident. A settings form is capped at 34rem. The page is 80rem because it holds tables and side-by-side panes, and a field for a handle is not made better by being 80rem of it -- a fault visible in a browser and in nothing else, the markup being correct. Credential writes now land back on the page they were posted from rather than the post list, following the subscribers page's precedent. RIG_HOLD=1 keeps the rig up after its checks so a browser can be pointed at it; the rig tore down the one thing worth looking at. 11 new checks, and the ones that matter prove no secret reaches the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGcvCdLtKn3ekmyr8a9wzG

73 days agor1870400018:16314replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Give the console the width it has, and one way out

The management pages were held to a 52rem column built for prose. They are tables and side-by-side panes, so they now take 80rem and the editor's two panes get room to be read side by side. Running text inside them keeps a readable measure of its own. One way out, in one place. The header's "View site" link is now the same close cross the pages themselves use, at three quarters of its former size -- it was competing with the pages for attention while saying something the corner already says. Importing appears only where there is something to import. It reads a directory of Markdown on the server into the store, which is the migration path off `source: dir`; once a site has made that move the control can only overwrite what the site now writes in the console. Both deployed sites finished that migration, so it was permanent furniture explaining itself under every list. It now checks the directory and says nothing when it is empty. fe2o3_steel lib 131 pass, rig 49 + 34 pass, verified in a browser.

73 days agor1870400018:16298replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Make the console look like one thing

A pass over the site console for the things that make it read as unfinished: controls that do not line up, buttons that all shout, prose where a label would do, and pages that assume a site has three posts. Every quiet and every dangerous BUTTON has been drawing itself as the loud primary one. The base rule names the element -- `.mc-btn, button.mc-btn` -- and `button.mc-btn` outranks a bare `.mc-btn-quiet`, so erase, unsubscribe, import, send-test and filter were all rendering as the page's main action while the same classes on an <a> behaved. The modifiers now name the element too. This is why the console looked like three different consoles. A row of controls is one height. A select carries its own intrinsic height and a text input another, so the editor's meta row stepped up and down across five fields; both are told the same number now, and the filter button is told it as well. The heading scale is set, not just its first rung. Setting h1 alone left h2 and h3 to the site's own stylesheet, whose scale is built for prose -- so on oxedyne the console's h2 came out larger than its h1 and every page with a section in it read upside down. The editor has a live preview beside the box, over the same render endpoint the app's Manage tab uses, so the separate preview page is no longer the only way to see the prose rendered. Its only verb is Save: leaving is a close in the corner, and deleting moved beside the post in the list, where a person is choosing between posts rather than working on one. The paragraph explaining what a title is has gone; the placeholder already says it. The lists cope with a site that is not new. Posts and subscribers both get a search, a state filter, a count that says how many of how many are shown, and a page at twenty rows -- filtering before slicing, so a search reaches the whole site. Row actions are icons. The subscribers page leads with its subject: the list first, then a Send a post section holding the send form, the test and the history, which were three loose things on a page. The state counts are a line, not a sentence restating the rules of the system on every visit. Verified by driving a real Steel in a browser and reading the screenshots, not by reading the markup: fe2o3_steel lib 131 pass, rig 49 + 34 pass.

73 days agor1870400018:16286replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

49 operations, since the previous mark · compare with the head
+Report on the list and on what has been sent to it

A Reports page in the site console, at /manage/reports: what the subscriber list is made of and how it grew, and what happened to the posts mailed to it. Both halves are aggregations over records the site already keeps -- the subscriber store and the send history -- so nothing here is measured for the purpose and nothing is asked of a reader. There is deliberately no open or click tracking. A tracking pixel and a rewritten link are surveillance of someone who asked only to be sent some prose, and the page says so where an operator would otherwise look for an open rate: an absence that is not explained reads as an oversight rather than a decision. The data has a ceiling and the page states it. A subscriber records the moment it signed up and nothing else, so growth is knowable and cohort behaviour is not; where a rate would be a guess, a share of the list as it stands is given and named as that. An address that confirmed and later left therefore counts only as left, which the page says rather than letting the confirmed share be read as a confirmation rate. by_month pins its unknown bucket last. A plain descending sort puts the word "unknown" above every real month, which is what the first run did and what the test caught. The console nav gained a Subscribers link as well. That page has existed since the newsletter landed and nothing pointed at it. Two stale rig checks fixed in passing: anonymous /manage renders a themed login rather than redirecting, since the passphrase login landed, and the non-admin refusal no longer names site_admins. Both had been failing since the change that made them wrong, unnoticed because the rig is run by hand. fe2o3_steel lib 131 pass, publish_store 8 pass, rig 22 + 49 pass.

74 days agor1870400018:16236replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Grow the Steel publish module into a blog and newsletter

The publish module gains the pieces a real blog and mailing list need, all generic to any Steel-hosted site and all in the site's own skin: - Tags: a post carries tags, the reader shows them and filters on `?tag=`, the feed carries them as categories, and a site-wide vocabulary accrues for the composer to click from. - Self-service site admins: the first sign-in on an unclaimed site claims it, an admin grants further admins by id from the console, and `site_admins` in config stays only as an operator failsafe. - Passphrase console login: a site is managed with the operator passphrase through a themed `/manage/login`, granting a least-privilege, content-only session (a separate `manage_session` cookie, never the operator's); an unauthenticated `/manage` renders that login in the site's own theme. - The console inherits each site's palette (its `mc-*` styling reads the site CSS variables) rather than being a generic dark panel. - Email newsletter, own-the-list: subscribers live in the vhost's Ozone with double opt-in, the newsletter is rendered, DKIM-signed and delivered direct-to-MX, and a site can send it without becoming a mail server -- the sender is built whenever a DKIM identity is configured, independent of the listeners. - List management in the console: per-state counts, CSV export, admin unsubscribe and erase, test-send, send history, and suppression of a permanently-bounced address. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd4QnXZbdTU5KyW5BszYMY

74 days agor1870400018:16225replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

122 operations, since the previous mark · compare with the head
+Tell a permanent SMTP rejection from a transient one

An outbound client that cannot distinguish a 5xx "no such mailbox" from a 4xx "try later" forces every caller to either retry forever or give up on both. Add `ErrTag::Permanent` and tag a 5xx reply to MAIL FROM, RCPT TO or the final DATA with it, carry the tag out through `deliver`'s collapsed per-MX error, and expose `smtp::client::is_permanent`. A newsletter sender uses it to suppress a bounced address without suppressing a transient one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd4QnXZbdTU5KyW5BszYMY

74 days agor1870400018:16102replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Lift UserGuard from fe2o3_shield to fe2o3_net beside AddressGuard

The per-IP AddressGuard was already generic in fe2o3_net and shared across HTTPS/SMTP/IMAP/SHIELD; its per-user counterpart UserGuard (a sharded map of Unknown/Blacklist/Whitelist trust with a caller-supplied data payload) stayed behind in fe2o3_shield. It carries no SHIELD protocol types, so it moves wholesale: relocated to fe2o3_net::guard::user, re-exported from fe2o3_shield::srv::guard::user so existing paths resolve unchanged. Now any protocol can classify users, not only addresses. No behavioural change; builds + lib/unit tests green (pre-existing shield lib.rs doctest failures unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHD7Ny5FVqM561ngW2cCKx

74 days agor1870400018:16093replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Replace the ? operator with the ok!() macro across the library

House style forbids the bare ? operator in favour of the ok!() and res!() macros. ok!(x) expands to exactly (x)?, so this is behaviour-preserving. 178 sites across net, steel, syntax, datime, units, core, crypto, o3db_sync, mail and the annealer binary; uses inside format specifiers, string literals, macro_rules! bodies, the annealer's test corpus and ?Sized bounds were left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:16089replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

333 operations, since the previous mark · compare with the head
+Document the public APIs of fe2o3_shield and fe2o3_o3db_sync

Add doc comments across the two worst-covered crates: shield's public surface is now fully documented, and o3db_sync rises from roughly a third to about two thirds of public items, prioritising the lib exports and the base, comm, data, file and bots types. Documentation only; no logic changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:15755replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

813 operations, since the previous mark · compare with the head
+Make string_to_digit_vec fallible and remove three production panics

string_to_digit_vec was a public function that panicked on any non-digit input behind an infallible signature; it now returns Outcome. Route the bot error counter through lock_mutex!, drop two unwraps and an identity format! when listing a directory in the tui, and correct British spellings in comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:14941replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+Remove the unwraps from the Saber KEM

Split the kr hash output into two fixed-size halves so the seed passes as &[u8; SEED_BYTES] without a fallible slice-to-array conversion, and split the matrix buffer with as_chunks. Make the WebAssembly encapsulation entry fallible so it validates caller-supplied key material at the boundary instead of panicking. The four KEM round-trip tests still pass; the crate now has no unwrap in the Saber path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:14919replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+Add poison-recovering lock macros and adopt them in the cert resolver

lock_read_or_recover!, lock_write_or_recover! and lock_mutex_or_recover! log a warning and continue via into_inner() rather than returning an error, for long-running services where a single poisoned lock must not cascade into a persistent failure. Steel's cert resolver hand-rolled exactly this recovery in seven places; route them through the macros. Other steel and datime lock sites deliberately degrade (return None or a default) and are left as they are. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:14890replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+Guarantee the annealer preserves the token stream, and stop it corrupting string literals

Add a workspace-wide semantic-preservation test that lexes, formats and re-lexes every source file and asserts the significant token stream is unchanged. Fix the two defects it found: the renderer's trailing-whitespace pass reached inside multi-line string literals (now trims only at layout breaks), and the number lexer consumed a field-access dot such as self.0.field. Also drop two provably-safe .expect() calls in favour of panic-free control flow, use lock_write! in access.rs, correct a spelling, and delete the dead text_old.rs module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbtC3stChXb4wHq1K1rcgN

74 days agor1870400018:14869replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Cover the credential store against a real database, and isolate the test dbs

Adds a real-Ozone test that destination credentials written to the database come back out of it, and that the effective set lays the stored credentials over the config's -- the store's Bluesky winning, the config's Mastodon surviving. Adding a second test to this file surfaced a harness bug: `test_db` keyed its temporary directory on the process id alone, so two tests in one binary -- which run in parallel by default -- shared one directory, and each call's `remove_dir_all` wiped the other's database mid-test. The post test failed intermittently the moment it had company. Fixed with a per-call counter in the directory name, so every `test_db` is its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14850replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Set a site's destination credentials from the console

Two endpoints behind the site-admin gate and the CSRF check. `GET /manage/creds.json` returns each remote's public fields -- an instance URL, a handle -- and whether its secret is set, and never the secret: it is write-only, so a session that should not have it cannot read it back. `POST /manage/creds` sets or clears a remote's credentials. Write-only, in three details that matter. The secret never comes back out of `creds.json`. An empty secret field with a secret already stored keeps the stored one, so a handle can be changed without re-typing a password, while a remote with no secret held requires one. And the log line names the remote and whether it was set or cleared, never the value, on the same footing as a login passphrase -- because a journal is read by whoever can read the host. Credential-setting is exempt from the "serve from the store first" gate a post write faces: a remote is a remote whatever the posts are served from. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14842replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Keep a site's destination credentials in its own store

Credentials can now live in the vhost's database, not only in the config file, so a site can be pointed at a Mastodon or Bluesky account without editing a file on the host. Stored there they are encrypted at rest under the database's own scheme -- the same treatment its posts, sessions and users get -- which is why a token entered this way is not a token sitting in a file in the clear. `DestCreds` gains a store round-trip (`to_dat`/`from_dat`, the reader lenient so a half-written or later-version remote is dropped rather than breaking a settings page) and `overlay`, which lays the store's credentials over the config's per remote: a remote set interactively wins, one left to the config still works, and a site may keep one in each. `get_creds`/`put_creds`/`effective_creds` are the store operations. Delivery and the composer's picker now both read the *effective* credentials, so what a site can be sent to and what its picker offers never disagree. Nothing writes to this store yet -- that is the settings form, next; until then `effective_creds` is exactly the config, so behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14832replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Link a post to where else it was published

A post's page now carries an "also on …" footer: a backlink to each remote the post reached, so a reader can follow it to where the conversation is. This is backfeed -- the return half of syndicating out, which without it is a one-way push that abandons the reader on the origin. `Post` gains `also_on`, filled by the store from a record's sent deliveries (a queued or failed one has no permalink to point at) and empty for a directory post, which records none. The links are `nofollow` -- these are the site's own syndicated copies, not endorsements to pass rank to -- and open in a new tab, since a reader following one has not finished with the page they are on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14823replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Tell the composer which remotes a site offers and a post has reached

Two additions the picker draws from. `/manage/status` now carries the destinations the site can post to -- the remotes it holds credentials for -- so the composer offers those and no others, and nothing where the site publishes only to its own pages. `post.json` now carries a post's deliveries: which remotes it has reached, their state, and the permalink of a sent one. The second is not cosmetic. Without it the picker could not pre-tick a destination a post had already been sent to, and a re-save -- which keeps only the ticked remotes -- would silently drop it, losing the record of where the post went. Showing the sent ones ticked is what keeps a save from unsending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14814replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Deliver a post to its destinations when it is saved live

Wires the composer's save to the outbox. The save form now carries a `destinations` field -- the remotes the author ticked -- kept to those the site holds credentials for, since a browser's word for a remote is no reason to queue a post the site cannot send. On a save of a live post, `queue_deliveries` derives or keeps a rendition per chosen remote, and `deliver_post` sends them then and there: the handler already holds the outbound TLS client and the database, and a save is the natural moment to use them. A draft delivers nowhere -- ticking a remote on a draft queues nothing until it is published. A delivery that fails records its failure on the post and does not fail the save; the post is written either way and the send is best-effort with its own state to show for it. A server with no outbound TLS client says so in the log rather than dropping the queue silently. `tls_client` is threaded from the web handler through the console gate into the composer for this; `handle_post` and `do_save` become async to await the sends. No background worker yet: delivery is synchronous at publish, and a failed one waits for the next save to retry within its backoff -- a timer-driven sweep is the remaining half of row 22. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14806replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+Configure a site's destinations, secrets and all

A vhost's `publish` block gains a `destinations` map: a per-remote credential block, `mastodon` (base_url + token) and `bluesky` (handle + app_password, host defaulting to the public PDS). A remote the site names no block for is one it does not post to. A block missing a required field is refused at load, not left to fail at send with the same cause. Credentials are secret references, resolved once at startup against the app root via the same `{env:}`/`{file:}` mechanism the SMTP submission password uses -- a token never sits in the config in the clear, and the public parts (an instance URL, a handle) are taken as written. This is the SMTP-submission precedent rather than the wallet: outbound-service secrets already resolve this way, and it needs no wallet plumbing. `DestCreds::offered` lists what a site can actually reach, which is what the composer's picker will show. Every capability is still `wired:false` until the composer calls the sender. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14784replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Walk a post's outbox in one pass, and queue by destination

`deliver_post` is the outbox sweep: it reads a post, attempts every delivery not yet done -- queued, or failed but not past retrying -- records a permalink and the moment on success or a bumped retry count on failure, and writes the record back once at the end. It holds no database lock across the network: read, release, send, write back, last-write-wins for the delivery log, because wedging the site on a remote that has stopped answering is the worse failure. `queue_deliveries` is what the composer calls when an author ticks destinations and saves. It queues, it does not send: a new destination gets a derived default rendition, a dropped one loses its delivery, and one already sent or hand-edited is kept as it stands so a re-save never re-derives over an edit or re-opens a delivery that has landed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14774replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Reach Mastodon and Bluesky, the two remotes that need no OAuth

The first senders behind the destination seam. Mastodon takes a static bearer token and one POST to /api/v1/statuses; Bluesky exchanges an app password for a session, then writes a feed post under the account's DID. Neither needs an OAuth client, which is why they go first: they exercise the whole delivery path for free. Each returns the permalink the remote gave back -- the address a backlink points at and the proof the post landed. The request bodies and the reply parsing are pure functions over strings, tested without a socket: the Mastodon status body, the Bluesky session and record bodies, the at:// URI turned into a bsky.app link, the permalink read out of a canned reply, the host pulled from a base URL. The network wrappers around them are as thin as they can be, because what a test cannot exercise against a live remote is what a test cannot catch. `DestCreds` holds a site's per-remote credentials (resolved from secret references, never in the config in the clear) and gates which destinations a site can offer. `deliver_one` is the one door every send goes through, and errors honestly on a destination with no credentials or no sender rather than reporting a post sent that never left. A delivery is an instant on a network, unlike a post's date (a day, which is why the feed is Atom), so this does reach for a clock and for `fe2o3_datime` to stamp it -- the fe2o3 calendar is the right tool for a Unix second, and exercising it is how its days-carry bug was just found and fixed. `fe2o3_datime` joins Steel's dependencies for it. Live delivery still needs the user's real tokens to verify end to end; no account can be fabricated for a test. Every capability stays wired:false until the composer offers the picker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14767replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Carry whole days when a CalClock duration is all seconds

`CalClock::add_duration` split the duration into a date part and a time part and added each in isolation. The date part read only the duration's `days` field, and the time part wrapped at midnight and dropped any overflow. So a duration built by `from_seconds` -- every second in the nanosecond field, nothing in the `days` field, which is exactly how `from_unix_timestamp_seconds` arrives -- advanced only the time, and every whole day was lost. `from_unix_timestamp_seconds(1_784_368_800, UTC)` came back as 1970-01-01T10:00:00Z instead of 2026-07-18T10:00:00Z. Fold the duration's day field and its nanoseconds, plus the time already held, into one nanosecond count, then split off whole days once with `div_euclid`. This keeps the days, and it also fixes the carry the old code lost even when the day field was right: a duration that pushes the held time past midnight now advances the date, and one that pushes it before midnight moves the date back. `subtract_duration` routes through `add_duration` of the negated duration so the carry is handled once. Three tests pin it: a Unix timestamp past its first day keeps its days and round-trips to the second; the epoch is still the epoch; a duration carries the date across midnight both ways. (The 12 pre-existing failures elsewhere in the crate -- leap seconds, TZif, Islamic calendar, holiday rules, relative parsing -- are unrelated and untouched.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14762replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Model where a post goes beyond its own pages

A post's own site is the origin -- a store write that cannot half-fail. Everywhere else is a delivery over an unreliable network, with its own state, its own retries and its own returned address. So the publish module gains a destination model, kept as pure data so it can be tested without a socket or a clock: - `Destination` (Email, Mastodon, Bluesky, Substack, X, Threads) with a `Capability` per remote -- length limit, whether a link is carried, media, and cost in micros (X being the only costed one). A picker reads the capability; a sender reads what it may send. A new remote is a new variant, and nothing that works has to change. - `Rendition` -- the words a destination gets, auto-derived by default, editable, and remembered once edited so a hand-written one is never overwritten by the automatic pass. `promo` keeps a link whole and lets the title give way to fit a length limit. - `Delivery` = destination + rendition + `DeliveryState` (Queued | Sent{at,permalink} | Failed{at,err,retries}), with a pure exponential backoff and a bounded retry count. `Record` carries `Vec<Delivery>`, persisted (absent when empty, like the date). A destination this version does not know is dropped, not misrouted or fatal; a delivery state it cannot read is a spent failure, not a queued re-send -- a post whose state is unreadable might already have gone. The composer carries deliveries forward across an edit and a rename, since the form has no destination picker yet. No sender is wired: every capability is `wired: false`. This is the seam the senders sit on (rows 21 and the delivery model of 22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVtWAybK4XbPZnWzcy8n6c

74 days agor1870400018:14755replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Render unsaved source, for a live preview beside the editor

The editor gained a second pane on a wide screen: the page a reader would get, rendered as the author types. A box a Djot ::: makes is seen where it lands rather than guessed at, which is most of what the preview is for. The parser is Rust, not JavaScript, so the preview cannot render in the browser; it posts the source to /manage/render and shows what comes back. That endpoint is a POST because a draft is too much for a query string, but it writes nothing -- it reads source and hands back HTML -- so it sits beside the writes under the same admin gate and CSRF check rather than inventing a second, looser door. render_html is the same parse and render a published post goes through, over source straight from the box. `parse_markup` is now the one place either front-end is chosen, shared by the save path and the preview so the two cannot drift on which reader a syntax gets. Verified on the rig: a ::: div posted to render comes back a box, and a Markdown `*bold*` comes back `<em>` -- the markers un-swapped through the live path too. 48 console checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14740replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Let a post choose Djot, and carry which it is

A post gains a markup: Markdown or Djot. Both read into the same tree, so the title, the excerpt, the feed and the page are made once and know neither -- the one place the syntax is chosen is render_source, which picks the reader and hands on a tree. The field defaults to Markdown, and a record written before it existed carries no markup key and reads as Markdown, which is what every such post was: no migration, on the rule this store already keeps for a field a later version adds. The composer offers it -- a "Written in" select, in the app's Manage tab and the server fallback both -- and the editor's field is labelled "Text" now rather than "Markdown", since it is no longer only that. The JSON the tab reads carries the markup so the editor opens on the right one. Verified end to end on the rig, which is the point of it: a member saves a post marked Djot whose source is `::: warning` and `[bright]{.hl}`, and the served page is `<div class="warning">` and `<span class="hl">` -- the box and the style Markdown could not name, reaching a reader. 45 console checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14731replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

26 operations, since the previous mark · compare with the head
+Read Djot into the document tree

A second front-end, beside the Markdown reader and producing the same tree, for prose that wants to name a box or a style. Djot is John MacFarlane's post-CommonMark redesign: linear-time, no backtracking, and -- the reason it earns its place here -- a syntax for the div and the attributed span the tree just gained, which Markdown has no way to write. It mirrors the Markdown reader: `block.rs` divides and gathers reference definitions, `inline.rs` scans and resolves, `mod.rs` is the thin `parse`. Same DEPTH_LIMIT guard against hostile nesting, same soft-break lesson -- a newline within a paragraph is a space, and `Inline::Break` is only ever the hard break a backslash asks for. Djot is not Markdown with different words, and the trap is the emphasis markers: `_` is emphasis and `*` is strong, single markers, the reverse of what a Markdown habit expects. `:::` opens a div, a class or an attribute group naming it; `[text]{...}` is an attributed span; a `{...}` line on its own attaches to the block that follows. Attributes -- `.class`, `#id`, `key=value` -- parse in one place, shared by divs, spans and standalone lines. Deferred, and passed through as the literal text they are rather than half-read: footnotes, math, definition lists, super/subscript, symbols, smart punctuation, raw blocks. The mod.rs doc comment lists them, so the next hand knows what is a gap and what is a decision. Built to a brief against the real tree; verified tree-level by its own 38 tests, and end to end by a djot-to-HTML test beside the renderer that pins the markers un-swapped, the box and span classed, and the deferred syntax literal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14704replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Let the document tree carry a named box and a styled span

The tree could say what prose means -- a heading, a list, a link -- but not that a region is an aside, or a span a highlight. Markdown has no way to write such a thing, so the tree never needed to hold one. Djot does, and so now the tree holds it: a `Block::Div` for a box, an `Inline::Span` for a styled run, each carrying `Attrs` -- an id, classes, key-value pairs. The attributes are OPAQUE, and that is the whole of how the tree carries a named box while still naming no output format. `{.warning}` says a region is in the class `warning`; it does not say what `warning` looks like. The name travels and the meaning is supplied where the tree is rendered -- a stylesheet for HTML, a style table for a signed document -- never here. A tree that resolved `warning` to a colour would be an HTML tree, and no longer the narrow waist between the syntaxes that read into it and the formats it is written out to. This was the design question parked as the AST owner's ("what is a style in a neutral tree"); the opaque answer is theirs to revisit, and is recorded for them in doc_ast_requests.md. It is the minimal, neutral choice, and it is Djot's own attribute model. The HTML renderer writes a div, a span, and each attribute out, every value escaped -- `write_attrs` is the one place a name becomes markup. `text_of` flattens a span's content as it does a link's; a div is a block, so a document's title, which is a top-level heading, is not sought inside one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14697replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Let the console answer JSON, so the app can render Manage in place

The console is a set of pages, but the site it belongs to is an app with tabs, and the operator asked to manage the site the way a reader reads it -- as one tab among the others, the rest still there to see what a visitor sees -- not by leaving for a page of its own. The pages stay, as a no-script fallback; alongside them the console now speaks JSON, so the app draws its own management surface over the same operations and gate. Three reads and a shape for the writes: - `GET /manage/list.json` -- every post, each state, drafts and all: the author's list, where the reader's `index.json` is the live posts only. - `GET /manage/post.json?slug=` -- one post's Markdown to edit and its rendering to preview. - `GET /manage/status` -- gains the CSRF token when the asker is an admin, since the app that writes cannot read the HttpOnly session cookie to derive it, and a cross-site page can reach neither the cookie nor this reply. - The writes (`save`/`delete`/`import`) answer JSON when the caller asks with `Accept: application/json`, and a redirect otherwise -- the app gets a yes it can act on without a page changing under it; a form still gets sent back to the list. The rig grows the app's path: status hands an admin the token, list.json and post.json read, a JSON save answers 200 not a redirect, a bad-token JSON write is a JSON 403 the app can read. 41 console checks. The tab itself is app-side (Elearnity's index.html/app.js/manage.css), deployed: hidden until status says admin, then it renders the list, the editor, a preview and import in Elearnity's own look. Verified loading clean and staying hidden for an anonymous visitor with a headless browser against the live site; the admin path is the operator's to confirm, since only they hold an admin account there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14684replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

40 operations, since the previous mark · compare with the head
+Dispatch the console on any publishing site, for the bootstrap

The first admin is a member the operator has not yet listed, and the console shows a signed-in member their id so they can ask to be added. But the dispatch guard required a non-empty admin list, so on a site with no admins yet the page that hands out the first id never ran: to become the first admin you had to already be one. The console now dispatches for a site that publishes as well as for one that has admins -- a publishing site has content to manage, so it has a console, and a member who signs in either manages it or learns their id. Writes still need a listed member; the gate denies an empty list, since a write names an admin and an empty list has none. Rig grows a second account, on no list, that signs in and is shown its own id and told what to ask for: 32 console checks.

75 days agor1870400018:14643replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Give a site its own console, apart from the server's

You asked to run Elearnity from Elearnity -- to sign in as its admin and manage it without being sent to a generic server panel that also holds the keys to the machine. That was the right instinct, and the old design had it wrong. The wallet passphrase was doing three unrelated jobs at once, and content management had been hung on the wrong one. Unsealing recovers the master key: a boot concern, done once from .env before any request. Server operation -- the wallet, the certificates, the seal -- is the /admin dashboard, the fuse box for the whole host. Site administration -- writing this site's posts -- is the site's own business. The composer sat behind the second because it was the fast path: the operator session already existed and already held the key the database needed. But by the time a post is written the database is long unsealed, so a site admin needs no wallet at all. The tiers separate cleanly. A site admin is now an ordinary member of the site whose username the operator has listed in the vhost's `site_admins`. No separate account, no separate password, no separate login: the site's own member login is the admin login, and the authority is being on the list. The list lives in config, not the site's database, so a content bug -- and this codebase has found more than one -- cannot mint an administrator. It is `#[optional]`; absent, the site has no console and /manage means what it did before. The console is at /manage, in the site's own look: server-rendered pages themed from the site's own stylesheets, so it works for any hosted site and not only a JavaScript one, and so the operator never leaves the site to run it. The member cookie is Path=/, so it already reaches /manage -- which is why the console can live here while the composer, behind the Path=/admin operator cookie, could not. A signed-in member who is not yet an admin is shown their own id and told to ask for it to be added: the bootstrap, made visible. Writes are gated on the member-admin session and guarded with a per-session CSRF token. The member cookie is SameSite=Lax, so a cross-site POST carries no cookie and never authenticates; the token is the belt to that braces, and what still holds if the cookie's policy is ever loosened. The composer is gone from the operator dashboard, its Publish nav with it: content authoring has left the server's panel entirely. To author, the operator adds themselves to a site's admins and uses that site's console like anyone else. Verified against a real Steel, as before, and this time it has to be: whether a member cookie reaches a handler is a fact about Path=/ and a browser, not a function. A member registers over the WebSocket and manages over HTTP with that one session -- an anonymous visitor refused, a signed-in non-admin refused, a listed member getting full CRUD, a write without the token refused, a slug with a path in it writing nothing, a deleted store-only post not resurrected by an import. 28 console checks, driven as a member admin over its own login, plus the operator tier proven still separate. The rig grew a node driver for the parts curl cannot reach: the WebSocket login whose cookie the console then reads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14636replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+Bake a transform into a path

Every transform in the crate is applied at fill time, so a path is written once and drawn wherever it is wanted. One caller cannot work that way: a glyph outline arrives in the font's frame, and the painter must hand it on to something that applies a transform of its own and takes only a path. Path::transform gives it a path already in the frame it wants. It maps the control points and no curve is approximated, since an affine map carries a Bezier to the Bezier through its mapped control points exactly -- the same fact flatten leans on when it flattens under a transform rather than transforming and then flattening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRBM43j6AfjV2TQxwcoDFL

75 days agor1870400018:14603replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Treat /admin/ as /admin, not as a route that does not exist

`/admin` redirected to the login form; `/admin/` 404'd with "Dashboard route not found." The dispatch in https.rs hands both to the handler, but the handler matched the root as exactly `/admin`, so the slash fell through to the not-found arm. A person types the slash, or a browser adds it to what looks like a directory, and either way they were told the dashboard is not there. Only the root is folded, on GET and POST. `/admin/traffic/` stays a real miss -- nothing serves it -- so this forgives the one slash that is ambiguous and no more. Rig covers both paths reaching the login now, 51 checks.

75 days agor1870400018:14600replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Let a day have an order, and let an essay announce itself

Two things a reader noticed, both real. ## A day is not an order Posts sorted by date, and two posts of one day fell back to sorting by slug -- alphabetically, which is to say arbitrarily, and not at all by which was written first. The day-only date came from the filename convention, where there was nowhere else to put a time and no front matter to invent one in. A record has no such excuse: its date is a field. It mattered most where most of the writing is meant to be. A note is the thing an author writes most, and several notes in a day is the ordinary case for the form, so the weakest part of the date model sat exactly where the traffic was going. A date may now name a minute: `2026-07-17T14:30`, still ISO, so it still sorts as text and still needs no calendar. A person may type the space they would write anyway and it is taken at the door, so one shape reaches the store. `<time>` gets the ISO form in its attribute and the readable one as its text, which is what it has both for. The feed's `instant` tested `len != 10` and returned the epoch otherwise -- silently. A date the store accepted and the feed dated to 1970 would sort to the bottom of every reader in the world and say nothing here, so there is now a test that every shape `valid_date` admits is a shape the feed dates properly. The two agreeing is not automatic and was not going to stay true by itself. ## An essay is not a long note The JSON the app's stream reads did not carry the post's kind, so the stream could not tell one from the other and showed everything whole. An essay stacked inline buries whatever follows it. The kinds exist precisely so a passing thought does not wear an essay's furniture, and the view was throwing that away and then giving the furniture to both. Notes render whole; essays become a card to open. The server says which; nothing guesses from length. No border and no panel on the card -- a box drawn round it is the same furniture, moved up a level. ## Found on the way `asides.css` had no `.aside` selector. The rule opening the file -- the prose's font, size, line height and colour -- had lost the line above it when the file was lifted out of `components.css`, so it read as a declaration block belonging to nothing. A browser hits the stray `}`, discards the lot, and the prose has been inheriting from the page ever since, which looks like a styling choice rather than a dropped line. Found by counting braces, not by looking. Rig: 47 checks. The one that matters is that the later post of a day now leads, and it is the post whose slug sorts last -- so alphabetical order cannot pass it by luck. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14596replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

39 operations, since the previous mark · compare with the head
+Keep the rig that found the last two bugs

It was thrown away, which was the mistake. The commit that built it argued that an in-crate test agreeing with itself is what let a DKIM signer sit broken for three months, then left the only thing that had ever tested this code from the outside in a temporary directory. `fe2o3_steel/tests/rig/run.sh` stands a real Steel up -- its own wallet, its own Ozone, its own certificate -- drives it over HTTPS with a real dashboard session, and tears it down. 37 checks. `cargo test` does not run it; it wants a port and half a minute, and it is meant to be run by hand. The checks are the ones that cannot be asked from inside. Whether the dashboard's cookie reaches a handler is a fact about Path=/admin and a browser, not about a function -- and the route the composer replaced was gated on a session that could never arrive, refusing its own author exactly as it refused a stranger. No unit test was ever going to see that. Nor that a draft is served to nobody while its author can still read it, that a renamed post takes its old key with it, that a deleted post stays deleted through an import, or that a slug with a path in it writes nothing. The README carries what the rig cost to learn: wallet creation needs a pty because crossterm reads the passphrase in raw mode, where Enter is a carriage return; `server -d` or a first run refuses production mode and exits 0 in silence; stdin must stay open or the shell beside the listener ends the process; `dev_cfg` is a required config block. It kills the server rather than the subshell that launched it. The first cut got that wrong and left Steel holding the port after saying it had finished. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14556replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Let the import run before the switch it exists to precede

The guard refused every write while a vhost served its posts from a directory, import included. That made the only path into the store: set source to "store", restart, watch the site go blank, then discover the thing which would fill it is refusing to run because the site is not yet serving what it has not yet been given. Import now runs whatever the source, because it is how a site gets from one to the other and it has to come first. The editor still waits for the store: editing what is not served is writing into the dark. So the order is import, look, switch -- and the site stays up across all three. Verified on the rig: serving from the directory, import returns 303 and /posts stays 200 throughout; a save is still refused with the reason; after flipping source to "store" and restarting, the prose is still there and the post still serves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14546replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+Give a site's prose a composer, and read a query where it is

The store has been there since this morning and nothing could write to it. This is the other half: a list of what a site has written, an editor, a preview of a draft, and a way to read a directory in. It lives at /admin/publish, not under the prose's own prefix, and that is the whole design. The dashboard's cookie is Path=/admin and SameSite=Strict, so a browser sends it to /admin and nowhere else. The import route built this morning at {path}/import was gated on that session and could never have authorised anyone: the gate was right and the cookie was never going to arrive. It answered 404 to its own author exactly as it would to a stranger, which is what a correct gate looks like from the outside, and why nothing about it looked wrong. That route is gone; write.rs with it. A slug is checked before it reaches a key. It is pasted into publish/post/<slug> and into a URL, and a form's word for one is not a reason to trust it: a slash would name another post's key, a dot pair would climb out of it. The rule is a small alphabet rather than a list of what to reject, because whatever such a list missed would be allowed, and that mistake does not announce itself. A date is checked for shape and not for the calendar. 2026-02-31 passes. Refusing it would mean owning a calendar, which is the dependency this module does not have and the reason the feed is Atom. There is no title field: a post's title is its own most prominent heading, and a field would be a second place for it to be wrong. store::list now has list_records under it, because a composer that could not show a draft would be showing everything except the work in progress. ## The database page's search has never worked Found while building the same thing here. A request's path and query are parsed apart -- HttpLocator keeps path and query as separate fields -- so request_path never holds a `?`. ozone_view split it on one, never found it, and read every request as though it carried no query. The prefix box, the limit box and the detail panel all did nothing; the page always listed the first 500 keys of the whole database, whatever was asked of it. The dispatch tested for "/admin/database?" too, which cannot match either. It fails the way a mistake like this always does: the page renders, the form submits, the URL changes, and the results look plausible because they are real keys. Nothing errors. Fixed in ozone_view and in the local listener, which drops the query the same way, and both now take it as its own argument. Verified against a real Steel rather than in-crate: a throwaway rig with its own wallet and Ozone, driven over HTTPS with a real dashboard cookie -- 34 checks covering the gate refusing an anonymous write, the cookie's Path being what this design rests on, a draft 404ing to a reader while its author previews it, a renamed post taking its old key with it, a deleted post staying deleted through an import, and a slug with a path in it writing nothing. The store's own history is the reason for that last pair: a scan offers keys marked for deletion, and an in-crate test agreeing with itself is what let a DKIM signer sit broken for three months. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14540replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

37 operations, since the previous mark · compare with the head
+Answer whoami in the language its only client speaks

Sign in, reload the page, and you were signed out. Not really: the session cookie was still good and the server would still answer as you. The page had simply asked who it was talking to and could not read the reply. whoami returned a jdat map. jdat writes a bool as `(true)`, which is not JSON, and the browser carries no jdat reader, so o3db.js handed the map back as a string and app.js read `undefined` off it. Every session looked signed out to the page that asked. Every other value on this wire is already JSON inside a jdat string, and not by accident: the client puts JSON in with sess_put and gets the same string back from sess_get. The server is keeping a string, so a string comes out. whoami is the one answer the server composes itself, which is what made it the exception. Now it keeps the convention the rest of the wire keeps. Dat::json does the rendering, so nothing writes JSON by hand -- the encoder's JSON mode is the same one the ACME client already decodes with. The map was built three times in the branches of one command; it is built once now, which is why the diff moves more than the fix needs. term_new returns a bare map too and is left alone: no browser calls it, and a command whose consumer cannot be checked is not one to change on a convention argument. The reasoning is in whoami_dat's doc comment if it ever gains one. Nothing could reach this before today. Steel had been sealed since 15 July, so no member could sign in at all, and the first sign-in was the first time the reply was ever read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WkgDNQnDYzRFnASmWVkmtc

75 days agor1870400018:14502replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Read an SVG path's geometry, arcs and all

A vector mark is drawn in a drawing program and leaves it as SVG, so every icon and every logo the crate could draw was either hand-coded in Rust or impossible. The crate had a rasteriser, paths, strokes and transforms, and no way to read the format all of that geometry arrives in. It reads the d attribute and nothing else. Path data is a small closed grammar and the one part every drawing program agrees on, which makes it the place to let a mark in without letting in a document format: no XML, no styling, no referenced content. The caller keeps whatever it wants of the file and hands the geometry over. Elliptical arcs have no Seg of their own, so they become cubic beziers on the way in and no caller learns they were ever arcs. SVG states an arc by where it ends and which of four candidate arcs to take; a bezier needs a centre and two angles. That conversion is the specification's own, and it runs in f64 though the path is f32, because the centre falls out of a difference of squares that cancels badly near the degenerate cases. The grammar is looser than it looks and most of the parser is that looseness: 1.5.5 is two numbers, -1-2 is two more, an arc's flags need no separator, and a repeated moveto is a lineto. A reader that got any of these wrong would not fail -- it would quietly draw the wrong shape. The tests are therefore checked against Chromium rather than against themselves. Asserting on the segments the parser emits cannot catch a wrong arc centre or a reversed sweep, which produce perfectly well-formed cubics; the unit tests pass on all of it. So tests/svg/ holds path data beside Chromium's rendering of the same bytes, and the comparison is coverage against coverage. Four of the fixtures are lifted verbatim from a real drawing program's output rather than composed by hand, so they carry the forms nobody writing fixtures would think to write -- an exponent inside a run of unseparated numbers, two-subpath donuts that only come out as rings under the non-zero rule. Worst agreement across the eleven fixtures is a mean coverage difference of 0.0012, which is the anti-aliasing either side of an edge. Each of five deliberate mutations -- wrong centre sign, no sweep correction, no smooth-curve reflection, ignored rotation, unread exponent -- is caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRBM43j6AfjV2TQxwcoDFL

75 days agor1870400018:14494replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

53 operations, since the previous mark · compare with the head
+Stop the debug log from writing passphrases down

The WebSocket handler logged every text message a client sent, verbatim, at debug. Two of those messages carry a passphrase in the clear: `register` and `login` both take one as their second argument, because the wire is TLS and the server needs the passphrase in order to hash it. That is fine on the wire and not fine in a journal. A journal is plain text, is readable by anyone who can read the host, and outlives the request by however long the log is kept -- so a debug session opened to chase an unrelated bug would quietly start writing down every passphrase anyone typed, including the operator's own if they put it in the wrong box. Nothing was leaked in practice: karri logs at info, so the line never fired. That is luck rather than design, and the next person to raise the level would not know they were arming it. Redaction is by command name, not by guessing which argument looks secret. A redactor that looked for something secret-shaped would be wrong the first time a command carried a secret in a new position, and wrong silently. An unknown command is logged whole, which is right for a vocabulary whose two exceptions are named -- and the doc comment says so, because a new command carrying a secret has to be added there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUxqvp29qGsTeiXt59wUYh

75 days agor1870400018:14440replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Say where fault! is, and why warn! carries no error

A caller went looking for a way to log a plain message at error level, did not find fault!, and reported the absence as a gap in error!. The macro was already there; nothing pointed at it. error! now says so, and log! says so too -- it previously misspelled its own advice as "user error! directly". warn! gains the opposite note. It genuinely cannot take an error, and the reason is not obvious from reading it: the stream arm is already ($stream:expr, $lit:literal, ...), so an error arm would be pattern- identical and never match. Better to record that where the next person looks than to leave them deriving it. Comments only; no API change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPzz37cFNr1kMpKacnAnFA

75 days agor1870400018:14436replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Keep a site's posts in its database

The store the composer needs. A vhost's `source` chooses it or a directory; both give the same Post, so nothing downstream of the read knows which it was, and the read is the only part that touches the database -- the five type parameters it drags along stop there and the renderers are plain functions over a slice of posts. A record holds the Markdown, not the HTML. The renderer improves; it gained tables this morning. A stored rendering would be a photograph of what the renderer used to do. There is an index record, because Database::scan is documented as O(database size) and a vhost's database is not only posts -- it is sessions, users, whatever else the app keeps. Listing ten asides by walking all of that would tie the cost of a page to how busy the site has been. So slugs live in one record and a post under its own key: a read of the index and a read per post, and nothing scans. rebuild_index is the repair for when that index is wrong, and it reads back every key the scan offers rather than trusting it. Database::delete "marks for deletion" and a marked key still comes back from a scan -- so a rebuild that believed the scan would resurrect every post ever deleted, silently, long after the deletion. An integration test against a real Ozone found this; no unit test could have, and the trait said so in prose I had read. A post is a note or an essay, and a draft or live. An unknown kind reads as a note, that being the lesser claim. An unknown *state* reads as a draft, deliberately: a record this version cannot make sense of must not thereby become published. Writes are gated on the dashboard's session -- the identity that unsealed the server. An earlier plan had this reusing the app's login and a role flag, which was written when the module was going to live in the app's own process, where that session is not visible. It lives here now. tests/common/mod.rs starts a real Ozone the way the server does. Starting it any less completely leaves the bots up but not answering, and every read fails on a five-second responder timeout rather than saying so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUxqvp29qGsTeiXt59wUYh

75 days agor1870400018:14431replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

55 operations, since the previous mark · compare with the head
+Blur a pixmap, and let a shape cast a shadow

A renderer could fill a path and stroke it, but nothing in the crate could soften anything, so nothing built on it could draw a shadow -- and a shadow is what makes a card read as a card rather than as a patch of a different colour. Three box passes approximate a Gaussian closely enough that the difference is invisible in a shadow, and a running sum makes each pass cost the same whatever the radius: a 512 by 512 pixmap takes 15.9ms at radius 1 and 17.7ms at radius 128. The blur works on premultiplied alpha and converts back afterwards. Blurring straight alpha bleeds the colour of transparent pixels into their neighbours, which fringes every soft edge with whatever happened to be sitting in the colour channels of pixels that were not there. Bounds::grow comes with it: stroke, blur and shadow all reach past the geometry they are given, and each was working out how far by hand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPyA7L5Wwvzbde2E3yp9jV

75 days agor1870400018:14375replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Serve a site's prose as pages, a feed and a list

A post that exists only inside a page's JavaScript cannot be linked to, cannot be found, and unfurls to nothing when it is pasted anywhere. Self-hosting prose in order to be read, and then serving it in a form only a browser running scripts can see, gives up the thing it was for. So a post's canonical form is now a page: a URL, HTML in the first response, and the tags a card is built from. The JSON list stays as the convenience for a page that would rather render them itself. The module moves into the server process, which is where the vhost's database is. It does not need one yet -- the posts are a directory of Markdown -- but the store cannot be built anywhere else, so this is where it has to sit. The app-side handler and its loopback proxy go away with it: one config block rather than a route, a registration and a proxy entry, and the app binary is no longer involved in publishing at all. A vhost's `publish` block is absent by default, which is what every config written before it says, so an existing config loads unchanged. Its own fields all have defaults: the shape of a config is not the place to discover a typo in a path. The feed is Atom rather than RSS, because of the dates. RSS wants an RFC 822 `pubDate` with a leading day name, which is a calendar calculation and would mean owning a calendar here or taking a dependency for one field. Atom wants ISO 8601, which a file named 2026-07-17-on-rent.md is already most of the way to. A page's markup is structural and names no styling; the site says what prose looks like, via stylesheets it lists. A server that shipped a font would be deciding something that is not its to decide. Escaping is exported from fe2o3_text rather than repeated: a page built around a rendered document puts that document's title into an attribute and into JSON-LD, and the rule should be stated once. The JSON-LD escape is the sharper one -- a title containing `</script>` would otherwise end the block and everything after it would be markup -- and is tested. tests/cfg_parse_check.rs parses a real deployed config with this build, skipping unless STEEL_CONFIG_CHECK names one. Adding a config field that karri's config did not carry is what took oxegen.io and elearnity down for 75 seconds on 2026-07-14; a hand-written test config passed then, because it had the new field. This one reads the target's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUxqvp29qGsTeiXt59wUYh

75 days agor1870400018:14367replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+Give Steel a publish module, reading a site's prose from Markdown

The first cut: a directory of Markdown becomes a list of rendered posts, newest first, for a site to show under whatever name it gives them. Prose reaches HTML through the document tree, so a post is rendered by the same tree a signed document is built from, and gains any front-end that tree gains. Posts belong in the vhost's database and will live there. They cannot yet: the database is held by the server process, and an API handler runs in whichever process registered it, which for an app keeping its handlers in a separate binary is not the server. So the store waits on this module sitting beside the database rather than beside the request. A directory of Markdown is not a stand-in for that -- it is a real way to write, and the file is the source either way; only what holds it changes. A file names itself: 2026-07-17-on-rent.md is the post on-rent, dated 2026-07-17. The title is the document's own most prominent heading, so a post says its title once, in the prose. No front matter: a metadata block is a second little language to learn, to parse and to get wrong, and everything it would say is already in the file or its name. A file that will not read or will not parse is passed over with a complaint in the log rather than failing the lot. The directory failing is an error, because an empty shelf looks like the truth and is not. The module names its posts nothing. A site calling them "Asides" says so in its own config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUxqvp29qGsTeiXt59wUYh

75 days agor1870400018:14338replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Read HTML into the document tree, and round a rectangle's corners

Three things that turned out to be one thing: getting an author's own books into a reader. The books are written in Typst, and a Typst chapter cannot be parsed for its prose without evaluating it -- the macros that carry half the words are the author's own, and only their own templates know what they mean. But Typst exports HTML, and in doing so it evaluates them. So the road to a Typst chapter runs through HTML, and the tree gains a reader for it. The reader faces the writer that was already there, and they were written apart and share only the tree: a round trip through both is two implementations checking each other rather than one agreeing with itself. Whitespace is the whole of the difficulty. HTML collapses it and the tree must too, or every paragraph of a book arrives frozen at the width the exporter happened to emit. This is the soft line break of the Markdown reader, mirrored: there a newline had to become a space, here a run of them must. Both exist so that prose reflows to the width it is read at rather than the width it was written at. Sixty re-wrappings of a real chapter give sixty identical trees. top_heading answers the question a caller actually has. Which level a piece is headed by says where the prose came from rather than what it says: an author writing Markdown heads a chapter with a level 1, and the same chapter exported from Typst arrives headed by a level 2, since the exporter keeps level 1 for the document it thinks it is making. Asking for level 1 found no title in a whole shelf of books and named every one of them after its file. Path::round_rect is the primitive a rounded corner needs, beside rect and ellipse and sharing their quarter-arc constant. A radius of nothing returns the rectangle exactly, so a corner nobody asked to round costs nothing and changes no pixel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJmyp2Q1Jce5F417MU1SDA

75 days agor1870400018:14334replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Write the document tree out as HTML

The counterpart to the Markdown reader, and the tree's second consumer. Until now the tree had one, and "it names no output format" was a claim rather than a tested one: the signed-document consumer is the constrained one, so it could not tell a neutral tree from an SBJ-shaped tree. This can. A rule, an image by its path and a code span within a line all reach HTML intact, and each is something SBJ gives up at its own boundary. A fragment, not a page: the furniture around a document belongs to the site. Every run of text and attribute value is escaped on the way out. A link's destination is written as given, which is right for prose whose author is trusted; untrusted prose wants a sanitiser between the front-end and here. Alignment reaches CSS as the logical `start` and `end`, never `left` and `right`, for the same reason Align names its sides that way -- the side text begins on depends on which way it runs, and only the thing laying it out knows. `Centre` spells itself `center` there, the keyword being CSS's. The tree carries no tight/loose distinction, that being presentation rather than meaning, so it is inferred: an item that is one paragraph renders without a `<p>`, and anything richer renders as the blocks it is. Tags are pushed rather than formatted, this crate's own fmt module shadowing the fmt! macro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUxqvp29qGsTeiXt59wUYh

75 days agor1870400018:14315replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Give the document tree tables, and Markdown a way to write them

A second consumer wanted tables and the tree had none, so prose that had a table lost it to a run of literal pipes. The tree gains Block::Table, and the Markdown reader learns GitHub's pipe tables, together -- a variant no front-end can produce is the same dead weight as a format kind no author can name. A cell holds inlines and not blocks. A cell is a phrase, and a tree that admitted a list or a quotation there would promise every consumer a cell it must lay out as a document of its own. The alignment is Start, Centre and End, and is never Left and Right. The tree does not know left from right, because it does not know which way its text runs: this crate ships the bidirectional algorithm precisely because the prose it carries may run right to left, and a column aligned to the start of the line is then on the right of the page. A tree that said Left would be wrong for half the world's prose, and wrong silently -- the table would lay out, and lay out backwards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJmyp2Q1Jce5F417MU1SDA

75 days agor1870400018:14310replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Promote the document tree out of the Markdown reader

A tree that names no syntax has no business living in a module named after one. A second front-end is being built against it, and a third is wanted for Typst, so the misnomer was about to become three misnomers and a lie. The tree moves to fe2o3_text::doc and Markdown becomes what it always was -- one front-end of several, at fe2o3_text::doc::markdown. Nothing about either changes but the path. Done now because it is cheapest now. The move costs one crate and one caller today; it costs every use line downstream a week from now, and the whole point of a neutral tree is that there are eventually many of them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJmyp2Q1Jce5F417MU1SDA

75 days agor1870400018:14291replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Teach the text crate to read Markdown

Most prose is written in Markdown, and a great deal of it already exists; nothing in Hematite could read a word of it. The reader produces a neutral tree of blocks and inlines that says what the prose is -- a heading, a paragraph, a quotation -- and nothing about how it should look or what a caller means to do with it, so a second syntax could produce the same tree and every consumer would keep working. The dialect is the CommonMark core that prose actually uses, not the conformance suite, which is largely a catalogue of nesting no author writes. Where the two differ on such input, this reader simply makes its own choice. Two decisions worth the reader's time. A soft line break says a space, not a newline: where an author's editor wrapped a line is not where the author meant a break, and preserving it would freeze prose at the width it was typed at instead of reflowing to the width it is read at. And the depth limit measures the tree, not the recursion: a long delimiter run nests one emphasis per pair iteratively, so a recursion guard never fires, and sixty thousand asterisks would build a tree that overflows the stack of whatever walks it -- including this crate's own helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJmyp2Q1Jce5F417MU1SDA

75 days agor1870400018:14268replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Forward the query string through the Steel reverse proxy

A prefix-proxy vhost rebuilt the upstream request line from loc.path alone, so any `?key=value` on a proxied request was dropped and an upstream that dispatches on a query parameter silently got the default. HttpLocator now retains the raw query verbatim (the parse into `data` is lossy and must not be used to reconstruct a target), and both proxy paths -- HTTP and the WebSocket upgrade -- append it to the forwarded path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsZtMqjZrGjMpEN1Jcf7d2

76 days agor1870400018:14260replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+Add circle and ellipse constructors to graphics Path

A circle is concentric-disc chrome's building block (the Oxeye eye), and an ellipse the general case; both as four-cubic bezier approximations, accurate to about a part in a thousand of the radius. Generic enough that any diagram or chart caller wants them, so they live here rather than in a downstream app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LU53wxvhSuZf5MY4chBtCA

77 days agor1870400018:14239replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give the graphics crate a QR Code encoder

Port Nayuki's QR Code generator into fe2o3_graphics as a new qr module, from first principles and with no new dependency. Byte-mode segments, Reed-Solomon over GF(256), version selection across the four error-correction levels, the function patterns, and the eight data masks with penalty-driven selection, all producing the module matrix a renderer needs. Only the grid is produced; turning it into pixels is the caller's job. Numeric and alphanumeric segment modes are deliberately left out. Pin correctness against an external oracle: five golden matrices from Nayuki's own qrcodegen, spanning versions 1, 2, 4 and 11 and every ECC level, are asserted module for module, alongside structural tests for the finders, timing lines, version selection and argument validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zYc7Lu6Qe9ecfxubeArrZ

77 days agor1870400018:14236replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Wrap outside pixels as a pixmap, and union two boxes into one

Pixmap::from_data takes RGBA that came from a decoder, a capture or another process and calls it a pixmap, refusing a buffer that is not exactly w*h*4 rather than padding or cutting it. Bounds::union is the smallest box holding two, the counterpart of intersect, for anything gathering several boxes into the one that contains them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCFjBW4qpTzdR2E2EYzCDL

77 days agor1870400018:14229replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Give a cursor the moves Unicode already knew how to make

`segment` could say where a grapheme cluster ended and where a word began, but a caret needs more than boundaries: it needs to be moved to the next word, moved to the previous one, and snapped back onto the character grid when a click lands inside a letter. Each caller was left to write those over the boundary vector, and the first one duly did. `next_word`, `prev_word`, `is_grapheme_boundary` and `snap_grapheme` are the mirror of the `next_grapheme` and `prev_grapheme` already here, and they are what a text field in any downstream app will reach for. Snapping rounds to the nearest boundary rather than truncating, since truncating puts the caret before the letter a reader clicked the right-hand half of, and makes the last half of every character unreachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXWJdnDCpE3A3i7nTJKck8

78 days agor1870400018:14226replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Pin the BDAT encoding to golden bytes, and give a c64 one encoding only

The binary suite encoded eighty-seven daticles and decoded them again with our own decoder. That proves the two agree with each other, which they would go on doing if every length prefix turned little-endian tomorrow: ninety-eight assertions and eleven byte literals in nine hundred lines, so almost nothing asserted a byte. These bytes are not an implementation detail. o3db hashes the encoding of a key to decide which node owns the record, so a byte that changes is a record that moves, and a value written by one release must encode identically under the next or it is simply gone. Nothing outside this crate can be consulted about that, because BDAT is ours -- so the vectors are written out by hand from the format's own rules (the kind codes, the big-endian payloads, the c64 whose code carries its own length, the list whose count is a byte length and not an item count) and the encoder is checked against them, rather than the other way round. Every one of them already held: the format is what it says it is, and now it stays that way. A c64 turned out to have more than one encoding. The code says how many bytes follow, so five is 0x21 0x05, and it was also 0x22 0x00 0x05, and 0x23 0x00 0x00 0x05, up to eight. The encoder only ever writes the first; the decoder read them all. One value with an unbounded set of valid byte strings is not untidiness when the bytes choose the node: a key re-encoded with a leading zero hashes elsewhere, and the record is written in one place and looked for in another. A signature over a canonical encoding fails from the other end. A leading zero is now refused, and the eighty-seven round trips still pass, because the encoder never wrote one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14223replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Stop a failed certificate renewal from being unable to retry

The client POSTed every challenge it was given, including the challenges of authorisations the CA had already marked valid. Boulder answers that with `400 malformed`, so the first issuance for a name worked and any retry after a transient failure could not: the CA caches a successful validation (RFC 8555 §7.1.4), so the second attempt is handed back a valid authorisation, POSTs it anyway, and is rejected. A certificate that stops renewing does not fail loudly -- it expires, on a schedule of its own choosing, some weeks after the code that doomed it ran. An authorisation is now read before it is acted on: one already valid is skipped, one still pending is proved, and one the CA is already validating has its challenge certificate installed but is not re-POSTed. The wire statuses of orders, authorisations and challenges are parsed into enums rather than compared as strings, so a status we do not know is an error at the boundary instead of a silent fall-through, and a CA problem document is surfaced with its own detail rather than a generic failure. The JWK thumbprint is now pinned to RFC 7638 §3.1's worked example, both the canonical JSON string and the SHA-256 thumbprint the RFC publishes. Let's Encrypt recomputes that value on every request we sign; our tests only ever asserted it was deterministic, which a wrong implementation is too. The canonicaliser is handed its members deliberately unsorted, so it is the code, not the test, that establishes the order the RFC demands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14216replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

44 operations, since the previous mark · compare with the head
+Read the PNG chunk that carries transparency

The decoder skipped tRNS as an ancillary chunk, on the reasoning that ancillary chunks are decoration and none of our business. tRNS is nominally ancillary but it is the one ancillary chunk that carries pixel data: it is where alpha lives for every colour type that has no alpha channel of its own. Skipping it did not drop decoration, it misread the image, and it broke the promise made three lines from the top of the file that an unsupported feature is refused by name rather than misread. Every pixel a transparent PNG declares transparent came back opaque, in silence. The chunk is now read for all three colour types that may carry one -- one alpha byte per palette entry, a transparent luminance, or a transparent RGB triple -- and refused, by name, for the two colour types the specification forbids it to. The reason this was invisible is the reason the DKIM signature was invisible for three months. Our encoder only ever writes RGBA, so it never emits a tRNS chunk, so no round-trip test could construct an input that would fail. The suite was green and structurally incapable of turning red. So the guard is a set of PNGs written by Pillow, checked against the pixels Pillow itself reads back from them: fixtures that did not come from us, and a reading that is not ours either. Against the old decoder all three transparent fixtures decode fully opaque and the test fails -- four of sixteen pixels in each, and eight in the palette image. The hand-built fixtures in the unit tests stay, but they were written by the same hand as the decoder and would have encoded the same misunderstanding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14171replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

27 operations, since the previous mark · compare with the head
+Make fe2o3_hash's tests run again, and stop Argon2 dropping half its parameters

The test suite had not compiled in months. Four targets failed to build against a Hasher API that had moved under them, so SHA3, Argon2id, CRC32 and the proof-of-work code had no executing tests at all, and a test that never compiles is indistinguishable from one that passes if nobody runs it. That silence is what hid the rest of this commit. The Argon2 PHC decoder read m and t and then stopped, so it dropped the lane count p, the tag length, and any associated data. It round-tripped against itself perfectly because we always wrote p=1 and always read lanes=1. The tag depends on p by design, so a configuration written by any other Argon2 implementation -- or by any future release of this one that chose more than one lane -- derived a different key. A wrong key here is indistinguishable from a wrong password: both surface as "no admin entry accepted the supplied password", which is the least debuggable failure a wallet has. The decoder now reads every parameter that changes the tag, refuses an option it does not recognise rather than ignoring it, and refuses to encode away a secret it was given. The oracle is RFC 9106 §5.3's own vector, which uses p=4 together with a secret and associated data -- precisely the three fields the decoder used to discard, so it fails against the old code on the first run. SHA3 is pinned to the NIST CAVP short-message vectors and CRC-32 to its published check value, rather than to our own output. A configuration string carrying no tag length is the format the previous release wrote, and it is in every deployed wallet, so it still decodes and still derives the key it always did. A wallet cannot be rebuilt from source; refusing to read one would lock its owner out of their own master key, which is the same silent failure by another road. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14143replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

85 operations, since the previous mark · compare with the head
+Stop three defects in the bytes Steel puts on the wire

All three are in code serving live traffic, and all three were invisible to a test suite that only ever read back what this crate itself wrote. A Rust Debug rendering was reaching the wire. HeaderFieldValue's Display wrote the Connection field with "{:?}", so every path that closes a connection -- six of them in Steel's https.rs, all error and teardown paths -- sent "connection: Some(Close)". The parentheses are delimiters, so the peer never saw the "close" token that told it to expect the teardown coming. The field is now the comma-separated token list RFC 9110 §7.6.1 asks for, and a Connection field holding nothing at all is left off the wire rather than sent as a bare name. A de-chunked body was still claiming to be chunked. Once the body is decoded it is no longer chunked, but the Transfer-Encoding field stayed on the message; write_all then added a Content-Length beside it. Both framing fields in one message is what RFC 9112 §6.1 forbids and §6.3 calls a request-smuggling signal. The field is now dropped when the body is decoded, and HeaderFields::insert holds the two apart by construction. Insert also replaces singleton fields rather than appending to them, which is what emitted karri's duplicate content-type on a 404: a stale entry in the order map wrote the field a second time. A header block split across two reads was silently lost. The terminator search ran over the latest read alone, so a CRLF CRLF straddling the join was never found, and the message vanished without an error. The search now runs over the accumulated bytes, resuming three back from the join. It survived because every test fed the reader a Cursor, which returns everything it holds in one read and so cannot exhibit the bug; the tests now use a scripted reader that hands back the bytes in whatever pieces the test chooses. Each of the six new tests was confirmed to fail against the old code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14057replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

37 operations, since the previous mark · compare with the head
+Pin two values a peer verifies to the RFCs' own vectors

The DKIM signer was broken for three months behind a green test suite, because every test fed our own verifier the canonical input our own signer chose. Two more values in this crate had the same shape: a third party computes them independently, and nothing here pinned them to anything but ourselves. The WebSocket accept key is one. It sat inside an impl block with seven generic parameters, including a Database, none of which it used -- which is why it had no test: you could not call it without standing up a database first. It is now a free function, pinned by the worked example in RFC 6455 §1.3, the value every browser recomputes before it will accept the handshake. DKIM's relaxed canonicalisation is the other. Last night's fix pinned the body hash to RFC 8463, but the ed25519 vector test signs a canonical string typed out by hand, so parse_header_block and relaxed_value -- the functions that canonicalise every real message -- were guarded by nothing. Both now run against the worked example in RFC 6376 §3.4.5. Both implementations turn out to be correct. That is not the point: they were correct and undefended, which is the state DKIM was in for three months before it wasn't. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ncejqGYuy4FXp8kdief5H

78 days agor1870400018:14019replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Give an error a form a person can read

fe2o3 had two renderings of an error and needed three. Display is the console's and colours itself with ANSI escapes. Debug is the developer's and names the file and the line of every frame it passed through. Neither belongs in front of somebody who did not write the program, and until now there was nothing else to reach for: the oxeweb reader, refusing a tampered document, showed its reader UpstreamErr{"sbj_wasm/src/lib.rs:79"} UpstreamErr{"sbj/src/doc.rs:64"} LocalErr{[Invalid Input Mismatch] "sbj/src/doc.rs:95: The 1895 byte tree region hashes to 1507362a..., but the envelope declares the hash 387a4f57..."} when the sentence at the end of it was the whole of what they needed to know. The words could not be recovered by trimming the outside of the chain, which is why this is a method rather than something a caller can do for itself: errmsg! puts the file and the line in front of every message it is given, so EACH frame carries its own, and a frame that merely wraps another carries a location and no words at all. plain() gives the words and nothing else -- no locations, no frame names, no tags, no escapes -- outermost first, since that is the context, and innermost last, since that is the detail. msgs() gives the same as a list, for a caller that would rather lay them out itself. A foreign error in the chain contributes its own Display, since that is all it has. Debug is untouched and remains what to reach for when debugging. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AD8gyLAHgS4C6pV1a7sMeE

78 days agor1870400018:14012replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Stop Steel opening every store with o3db's test chunk settings

new_db built its OzoneConfig as an exhaustive struct literal, and the literal had been copied from o3db_sync's test setup: a chunking threshold of 1,500 bytes and a chunk size of 64. The library's own defaults are 700 KiB and 100 KiB. So every store Steel opens -- its own, and every application built on it -- split any value past 1.5 KB into 64-byte pieces, each fetched by its own read request. The numbers are the symptom; the exhaustive literal is the fault. It has to restate every field, so a wrong one is invisible among the right ones, and a field added here cannot reach a caller who has already spelled them all out. It now starts from Default and names only the four things a Steel server deliberately wants different, so each of those is a decision and everything else tracks the library. This changes what a NEW store is opened with. An existing store keeps the settings in its own config.jdat, which is read in preference to these; correcting one is a matter of editing that file, and is safe -- a chunked value's geometry travels in its part key, so what was written under the old settings still reads under the new. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSwi9pnQCbbDZ6jRtHauUY

78 days agor1870400018:14008replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Read back a value that was too large to be stored in one piece

A value whose encoding exceeds the chunking threshold is split on the way in, and the key holds a part key naming its chunks. On the way out, fetch_chunks gathers them, rejoins the bytes, decrypts them and decodes them, so what it returns is already the caller's value, fully formed. get_wait took that for raw bytes and decoded it a second time. A list, a map or a string fell through to a catch-all and came back as "Unexpected Dat ... returned"; a byte string, the one kind whose arms matched, had its payload read as though it were itself an encoding. So every value large enough to be chunked was written perfectly well and could not be read: an accumulating value -- a ledger, an append-only list, a document -- worked until the day it crossed the threshold and then failed for good. The suite never ran this path. Its only chunking test calls fetch_chunks directly, bypassing get_wait, and stores a byte vector, which is precisely the kind that did not error. tests/chunked_value.rs now stores a list, a string and a byte string, each over the threshold, through the public API; it fails without this change. Its third session also changes the chunk geometry of an existing store and insists that what was written under the old settings still reads, since a value's geometry travels in its own part key and never comes from the current configuration -- which is what makes it safe to correct a store that was given the wrong one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSwi9pnQCbbDZ6jRtHauUY

78 days agor1870400018:14001replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Let the crypto that needs no C be built without it, and reach a browser

fe2o3_crypto could not be built for wasm32, and not by a little: its build script compiles SABER in C, links OpenSSL and runs bindgen, and pqcrypto-dilithium is C-backed too. None of that exists in a browser. The Ed25519 that a caller verifying a signature actually needs is pure Rust, and was being held back by post-quantum machinery it never touches. The C now sits behind a default-on `pq` feature: the SABER key exchange, the bindgen build, and the C wrapper behind SignatureScheme::Dilithium2. Turn it off and what remains is pure Rust -- Ed25519, AES-GCM, SHA3, and the pure-Rust Dilithium -- and compiles to wasm32. A native build with default features is unchanged. Asking for the C scheme in a build that does not carry it fails by name, saying which feature it wants and which pure-Rust scheme does the same job. A scheme that is absent is not a scheme that does not exist, and a caller deserves to be told which it is. This is what lets a signature be verified where it should be: on the machine of the person the signature is for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCcPNLsvGUvcoBZ6QtZEfh

78 days agor1870400018:13994replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

37 operations, since the previous mark · compare with the head
+Add fe2o3_graphics: paths, an analytic rasteriser, pixmaps and PNG

Painting is not geometry, which is why this sits beside fe2o3_geom rather than inside it. That crate serves integer layout, where a rectangle is a cell of a terminal. Here a coordinate is a float, a shape is a path of lines and Bezier curves, and the output is pixels. The rasteriser does not supersample. It accumulates the signed area each edge contributes to each pixel and takes a running sum along every row, which is the answer rather than an estimate of it: a pixel an edge cuts in half comes out exactly half covered. What the sum holds is the winding number averaged over the pixel's area, so a fill rule cannot be tested off it -- ask whether one and a half is odd and there is no answer -- but must be extended from the integers out to the reals. Non-zero's extension is a saturating absolute value, even-odd's a triangle wave, and that is the whole difference between them. A stroke is a fill of a different path, so the stroker adds no code to the rasteriser. Its outline is a union of convex pieces wound alike and cut to meet rather than overlap: pieces that overlapped would sum to two on the union's boundary and bead brightly at every join, where pieces meeting edge to edge sum to exactly one and the seam cannot be seen. The PNG codec owns its CRC-32 and refuses, by name, a decompression bomb, a sixteen-bit channel and an interlaced file. An image decoder is the classic place a viewer is attacked from, and owning it is a position, not pride. The only dependency is flate2, for the DEFLATE stream a PNG carries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCcPNLsvGUvcoBZ6QtZEfh

78 days agor1870400018:13956replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+Give fe2o3_text Unicode: normalisation, line breaking, segmentation, bidi

The library had none of it. No normalisation, so two byte sequences that display identically hashed differently and a content address was not a function of what it addressed. No line breaking, so a renderer could only guess. No segmentation, so a cursor moved by byte. No bidi, so Arabic and Hebrew came out backwards. Built here rather than taken from ICU4X, whose data-provider machinery is a larger thing to carry than the algorithms are to write: these are small logic over large tables, which is what this library is good at. The tables are generated from a pinned UCD 17.0.0 by src/bin/gen_unicode.rs and committed as Rust source -- no build.rs, no download at runtime, no new dependency. Every algorithm is held to the Unicode Consortium's own conformance suites and passes all of them: normalisation, grapheme and word segmentation, line breaking and both bidi suites, about two million cases. A test that cannot find its data fetches it, at the pinned version; a test that cannot fetch it fails. Nothing is skipped. The two bidi suites are 15 MB and are fetched rather than carried. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCcPNLsvGUvcoBZ6QtZEfh

78 days agor1870400018:13932replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

39 operations, since the previous mark · compare with the head
+Stop the JDAT text decoder dying at a nesting depth of thirteen

The binary decoder was given frame-splitting and a depth limit; the text decoder never was. It spent about 158 KB of stack on every level of nesting in an unoptimised build, so a two mebibyte thread -- what Rust gives a spawned thread by default -- aborted at a nesting depth of 13. A stack overflow aborts the process rather than returning an error, so it cannot be caught, and a couple of hundred bytes of nothing but brackets is the cheapest attack there is. The per-character body now lives in an inline(never) function that asks for a descent rather than taking it, so none of its locals sit on the stack across the recursion. That costs 2.4 KB a level instead of 158 KB, a factor of sixty-three, and the same thread now survives depth 789. The default limit is 512, measured rather than chosen: 512 levels at 2,496 bytes is 1.25 MiB inside a two mebibyte stack, so everything at or under the limit is safe by construction, and anything over it returns an error naming the depth, the limit and the character offset. Text depth counts brackets, braces and kindicles rather than values, so it does not share the binary decoder's 64: a caller nesting five text levels per logical node would find 64 far too few. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCcPNLsvGUvcoBZ6QtZEfh

78 days agor1870400018:13892replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Print a nested error plainly when asked to print it plainly

Error's Debug is documented as the plain form, without the ANSI colour codes Display uses for a console. It was plain only at the outermost frame: the nested error, which is one of ours and is downcast back to one, was then printed with Display, so every frame below the first came back coloured. Invisible in a terminal, where the escapes are what is wanted, which is why it went unnoticed. Anywhere else -- a browser, a log file, a JSON field -- they are rubbish in the middle of the message, and a caller reaching for Debug is reaching for it precisely because the message is not going to a console. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCcPNLsvGUvcoBZ6QtZEfh

78 days agor1870400018:13881replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Tell the client when mail arrives, instead of making it ask

IMAP had no IDLE, so a client polled: Thunderbird every ten minutes by default. Mail that had already arrived sat unannounced for up to ten minutes, and the client woke the server all day to be told nothing had happened. RFC 2177 inverts both halves of that bargain, and it is one command. The client sends IDLE, we answer `+ idling`, and until it sends DONE we watch the mailbox and push an untagged EXISTS the moment the count moves. A Maildir folder status is a directory listing, so the watch is cheap. Not `select!` between the socket and a ticker: that wants a mutable borrow of the stream in one branch and another in the handler of the other, and the usual workaround -- reading a byte at a time -- invites losing bytes, because a future dropped mid-line has already taken them off the socket. `AsyncReadExt::read` is cancel-safe, so a timeout around it is correct and simple, and the partial line outlives each attempt in a buffer. Alerts are now DKIM-signed as well. The alerter posts straight through the SMTP client rather than through the mail handler, so it was sending unsigned mail from a domain that signs everything else -- exactly what a spam filter is entitled to distrust, and the alert is the one message that has to arrive. The signer loading moves out of the mail listeners into `load_dkim_signers`, shared by both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13877replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+Give the fixture's mail a Message-ID, so a client's threading can be tested

A reply points back at the message it answers, and a mail client that gets that wrong delivers an unrelated message with a similar subject. The fixture's three seeded messages carried no Message-ID at all, so no client driven against it could be shown to thread -- an absence that reads as a pass. They carry one now, and the second names the first in References, so a fetched thread has a chain in it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBh2s11js4UsfFKrsAk1Fh

78 days agor1870400018:13856replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Sign the hash, not the headers: ed25519 DKIM has never verified

Gmail has been reporting dkim=fail on every message this stack ever sent, and nobody asked it. The mail arrived anyway, on SPF, and a failed signature is indistinguishable from no signature -- so a broken signer looked exactly like a working one for three months. RFC 8463 §3: ed25519-sha256 "signs the hash with the PureEdDSA variant Ed25519". The hash. Steel handed Ed25519 the canonicalised header block itself, and Ed25519 hashed *that* again internally with SHA-512, producing a flawless signature over a message no verifier computes. RSA was right by accident of a different API: ring's RSA_PKCS1_SHA256 takes the message and digests it itself, so passing the canonical bytes was correct there. The tests did not catch it because they were circular. They verified Steel's signature over Steel's own canonicalisation -- proving the crypto sound and the bytes self-consistent, which is precisely the property a wrong signer also has. Even the openssl cross-check fed openssl the input Steel chose. What none of them did was ask whether that input was the one a receiver computes. So the fix comes with RFC 8463's own test vector: the RFC's key, the RFC's canonical input, and the RFC's expected signature, reproduced byte for byte. It fails against the old code and passes against the new. The body-hash vector is pinned too -- it was already correct, and now it is held there. The lesson is not about DKIM. A test that only asks whether an implementation agrees with itself will pass for any consistent implementation, including a consistently wrong one. Somewhere a test has to compare against something that did not come from us. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13851replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Print the whole mail user file, not just the entry to paste into it

`mailpass` printed a bare entry under "Add this entry to your mail users.jdat", which invites exactly what I did: paste it into a bare list. The parser wants a map with a "users" list and rejects anything else with "no 'users' list", and the only clue that the wrapper exists is a doc comment in another crate. Print the file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13846replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+Sign mail with RSA as well as ed25519, and unblock the mail cutover

The DKIM signer was ed25519-only, and that was the one thing standing between karri and its own mail stack. ed25519 verification is still patchy in the wild -- Microsoft notably -- and a receiver that cannot verify a signature does not see a bad signature, it sees an *unsigned* message, leaving DMARC to rest on SPF alone. Not a stack to move real mail onto. The way through turned out to be much shorter than it looked. The standing plan was to implement RSA signing over fe2o3_num's bignum, on the grounds that ring refuses to generate RSA keys. But ring refuses to *generate* them; it signs with an existing one perfectly well, and it is already in the tree. So the key is made once, offline, with `openssl genpkey`, and ring signs with it -- no modular exponentiation to hand-write, and no timing side channel to get wrong. fe2o3_num has no modexp anyway. DkimKey is now an enum over the two algorithms, the key file is sniffed rather than declared, and Steel signs with every configured key in turn: two selectors, two signatures, each receiver takes whichever it understands, as RFC 8463 §5 asks. The signatures are independent because a DKIM-Signature field is not itself among the covered headers -- there is a test pinning exactly that, because if it were false both signatures would break and nobody would tell us. The RSA public key is published as a SubjectPublicKeyInfo, which is what verifiers parse and what `openssl rsa -pubout` emits; ring hands back a bare PKCS#1 key, so it is wrapped. Publishing ring's bytes directly would yield a record that looks plausible and that nothing on earth can read. Proved against an independent implementation: openssl verifies a signature this signer produced, using the public key this signer publishes. A signer that merely agrees with itself can still emit something every receiver rejects, and DKIM fails silently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13838replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

46 operations, since the previous mark · compare with the head
+Do not make a config block mandatory for a feature nobody switched on

Adding the `alerts` block to ServerConfig invalidated every config.jdat that did not have one, because FromDatMap treats a field as required unless it is marked `#[optional]`. Deploying the alerting build to karri therefore crash-looped a server that does not use alerting, and took the sites down for about a minute while adding a feature they never asked for. Marked optional. A config block for a feature that is off should be absent, not mandatory-and-empty, and any block added to this struct in future should be `#[optional]` too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13791replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Post the alert through a provider, so that it arrives

Delivering an alert straight to the recipient's MX asks that receiver to trust a message arriving unannounced and unauthenticated from a host it has never heard of. Karri has no PTR record; Gmail is entitled to bin the lot. The message that says something is wrong is exactly the one that must not land in a spam folder. An optional `submission` block posts the alert through the sender's own provider instead, over the SMTP AUTH path that 62a5d97 has just added to the client. The provider authenticates the sender and its reputation carries the message the rest of the way -- and the machine raising the alarm is no longer the only machine on the path, which was the point of alerting off-network in the first place. The credential is a {file:...} reference, not a wallet secret, and that is not an oversight: the alert that matters most is the one saying Steel came up *sealed*, and at that moment there is no master key to decrypt anything with. The alerter has to speak before the wallet is open, so its credential must be readable while sealed. Tested end to end against a stand-in provider: the alerter authenticates, sends the envelope, and the event reaches the message body. Alerting's failure mode is looking like cover, so it is worth proving the message leaves the building rather than only that the code was called. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13789replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Tell the operator when Steel is sealed, unsealed, or being guessed at

Steel could come up with its databases shut and say so only to a log file nobody was reading. From outside nothing looks wrong -- the websites serve -- so the operator's first news of it was a user complaint. It now sends an email. Three events, and the list is short on purpose: a sealed start, an unseal (who, when, from where), and a run of failed passphrase attempts. Nothing routine. An alerter that fires on every sign-in is one the operator learns to delete unread, and the message that mattered goes with the rest -- so an unseal is distinguished from the routine logins that follow it, and a single failed attempt, which is usually a typo, says nothing at all. Failures coalesce into one message per burst. The dashboard login unwraps the wallet master key, so it is worth guessing at, and an alerter that relayed every attempt would turn a brute-force run into a mail flood aimed at the operator's own mailbox -- doing the attacker's work for them. The mail notifies; it never authorises. There is no approve-by-clicking link and there never should be: an authorisation that arrives by email is one that anybody able to read, spoof or replay it holds too. It says what happened and points at /admin, where the human authenticates. Delivery goes straight to the recipient's MX through the in-tree SMTP client, so no relay is needed, and runs on its own task -- the request path never waits on an MX lookup, and an alerter that could take the site down would be a liability, not a safeguard. A failed delivery is logged loudly, saying both that the alert did not arrive and what the event was. WebHandler now receives the peer address. A handler that cannot see who it is talking to cannot audit a failed login: the audit log recorded every wrong passphrase as "(anon)" with no source at all. It now records the address. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13771replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

58 operations, since the previous mark · compare with the head
+Install a rustls provider rather than panicking without one

rustls resolves its crypto provider from process-global state and its builders panic when they cannot pick one unambiguously -- either because none is installed, or because the build enabled two and so has no default. Building fe2o3_net alone enables exactly one (aws_lc_rs, via tokio-rustls); building it in the workspace unifies features with fe2o3_steel's rustls "ring" and there are suddenly two. An application is expected to install one in main, and the Steel binary does. But fe2o3_steel is a library: an app crate with its own main that omits that line got a panic from deep inside a request path, at the first outbound TLS connection, rather than an error at start-up. A library that panics because its caller's main was missing a line is a landmine. default_client_config and the ACME trust store now install a provider if the application has not. Whichever one the application chose is kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13712replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Post mail through the account holder's own provider

The SMTP client could deliver but not submit, and the difference is who is being talked to. Delivery is server to server: look up the recipient's MX, connect on port 25, and hand the message over. Nobody authenticates, because the receiving server takes the mail on account of the recipient, not the sender. Submission is the conversation a mail *client* has, with the sender's own provider, and the provider carries nothing at all until the sender proves it holds the account. There was no AUTH in the client, so that conversation could not be had: fe2o3 could talk to servers that already wanted the message, and to no others. OutboundClient::submit takes a SubmissionConfig -- the provider, the port, the credential -- and speaks AUTH PLAIN, falling back to AUTH LOGIN, after STARTTLS on 587 or under implicit TLS on 465. It refuses to send the password to a server that offers no encryption, and refuses to go on when the login is rejected: a 535 says "not this credential", and for an account with two-factor authentication it says so because an ordinary password will never be accepted there. Delivery and submission differ in how they reach an open, secured stream and not at all in what they do with one, so they now share the MAIL/RCPT/DATA transaction rather than each keeping a copy. The second copy is where the dot-stuffing gets forgotten. The tests stand up a provider on loopback that demands a login, and check the client's half of the exchange: that PLAIN is chosen when offered and carries the credential correctly, that LOGIN is used when it is all there is, that a line beginning with a full stop arrives doubled, that a rejected login stops before MAIL FROM, and that a server offering only a mechanism we cannot speak is refused rather than spoken to anyway. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uw8f7WyoHpLWFt3wZjtUh

78 days agor1870400018:13707replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+Frame a deletion like any other write, so a store survives one

A deletion is appended as a tombstone under the deleted key, and the reader that replays a data file to rebuild its index cannot tell a tombstone from an insertion: it reads every record the same way, as a cache hash, the key, the chunk index, the metadata, a checksum, then the value and its checksum. An insertion was framed that way, by Encode::encode. A deletion built its write message by hand, dropped the cache hash on the floor, and appended no checksums. The replay therefore read the first four bytes of the tombstone's key as a cache hash, took the byte after them for a daticle kind code, and stopped: Dat identification code 104 not recognised 104 is the 'h' of "chal:". Every record written after the first deletion was lost, on every start, silently, while the database went on serving from a cache the file could no longer rebuild. A store that deletes anything routinely -- a consumed authentication challenge, an expired session -- lost nearly all of itself the first time it was restarted. The tombstone is now packaged by the one encoder both paths share. That also repairs the cache: the writer strips a fixed four bytes from the front of every key before filing it, so an unframed tombstone was filed under the key minus its first four bytes, and a deletion never marked the key it named. With deletions now reaching the key they name, a second gap shows: nothing ever constructed CacheEntry::Deleted, so a deleted key read back as a raw tombstone and callers had to know what one looked like. The distributed layer already carried that workaround. A deleted key now reads as absent in the core, once, for every caller. The regression test writes on both sides of a deletion, removes the index files, and insists that everything except the deleted key comes back. basic already removed the index files and restarted, but never deleted a key first, which is how this survived. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uw8f7WyoHpLWFt3wZjtUh

78 days agor1870400018:13687replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

14 operations, since the previous mark · compare with the head
+Report the seal only where it actually withholds something

A deployment of static sites, redirects and proxy routes has no database at all. Sealing it locks nothing: there is no key to want and nothing waiting on one. Reporting "the databases are shut" to that operator -- as the login banner and the startup warning both did, cheerfully announcing "the 0 configured database(s) are shut" -- is how a healthy server gets mistaken for a broken one. AdminState now knows how many databases are configured, and distinguishes being sealed (no master key) from the seal withholding data (no master key, and at least one database that needs it). The banner and the warning key off the latter; a sealed Steel with nothing to unseal logs one quiet line saying so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13672replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Serve the websites while the database is still locked

Steel demanded the wallet passphrase before it would bind anything. That made the database key a precondition for the websites being up, which is backwards -- a static site does not touch Ozone. It cost an outage at every cold start, which lasted until a human reached a terminal, and it made a headless start impossible: with no tty there is no prompt, so a service manager could only crash-loop. Steel now starts sealed. It loads the wallet without unlocking it, binds its listeners, serves every static vhost, follows its redirect and proxy routes and renews its certificates, all with the master key still unknown and the databases still shut. Nothing that needs the key runs before the key exists; nothing that does not need it waits. Unsealing is one act, and the dashboard login already performed most of it: it authenticated against Wallet::unlock and then discarded the master key that unlock recovered. It now keeps it. So signing in at /admin *is* the unseal, and an operator brings a cold-started Steel's databases up from a browser, with no terminal and no SSH -- which is the point, because the machine that needs unsealing is rarely the machine the operator is sitting at. The wallet file is readable while sealed (it holds only password-wrapped keys), which is what lets the unseal form authenticate with no database behind it. A shell `unseal` command does the same thing before `server` for an operator who is already at a terminal. The unseal is not gated on dashboard scope, though the session is. That is not an escalation: every admin in the wallet holds their own wrap of the master key and can already recover it by definition. vhost_dbs moves behind a shared lock so databases can be attached after the listeners bind; db_specs records what to open. The databases open on a blocking thread -- starting Ozone blocks, and on a single-core host doing that on the async worker would starve the accept loop -- and each is published as soon as it is up, so a ready vhost stops answering 503 without waiting for a slower sibling. One that fails to open leaves Steel sealed and still serving. The dashboard session key is no longer derived from the master key but minted at random per process, because a sealed Steel has none and must still issue a cookie to the admin on their way to the unseal page. There is still no at-rest secret. The wallet's threat model is unchanged; what has changed is that the websites no longer wait on it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13649replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

92 operations, since the previous mark · compare with the head
+Install a crypto provider in the ACME trust-store test

The test builds a rustls ClientConfig, and rustls resolves its crypto provider from process-global state. Built on its own, fe2o3_net enables exactly one provider (aws_lc_rs, via tokio-rustls's defaults) and rustls picks it. Built in the workspace, feature unification adds fe2o3_steel's rustls "ring" alongside it: two providers, no unambiguous default, and the builder panics. An application resolves this by installing a provider in main, as the Steel binary does. A test has no main, so it installs one itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13556replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Make the per-address rate limiter actually count requests

RingTimer::update bound `self.0` to a local before writing to it. RingBuffer derives Copy, so the timestamp went into a temporary that was then dropped and the ring stayed empty for ever. Every rate the type reported was zero, and the address guard built on it -- Steel's TCP accept-path limiter, its per-route auth limiter, and Shield's adaptive proof-of-work difficulty -- never fired once. Two further faults sat behind that one. set_and_adv advances past the slot it writes, so `curr` addresses the oldest entry, not the newest; reading the ring through get() as the newest inverted every interval and yielded Err from duration_since, collapsing the durations to zero even had the ring held data. And avg_rps divided by whole seconds, so a burst inside one second measured as a span of zero and reported a rate of zero -- the faster the flood, the smaller the number the limiter saw. Durations are now taken from the timestamps actually held, the span is measured in milliseconds, and a rate is reported only once the ring has filled, so a browser opening two connections a millisecond apart is not mistaken for a flood. A full ring inside a single millisecond reports the maximum rate rather than zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6U6FkPb5R5KB2JMZ6BGbL

78 days agor1870400018:13554replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Decode BDAT from an untrusted source without panicking or overflowing

A reader that did not create the bytes it is reading reaches the decoder before any signature is checked, so hostile input must be refused rather than trusted. - from_bytes_usr read buf[3] having checked only len > 2, and panicked on a three byte usr daticle. - from_bytes_abox indexed buf[start] with no bound after the inner value, and panicked when that value consumed the buffer. - from_bytes_map and from_bytes_list computed 1 + n + payload_len before the bound check, overflowing a usize on a payload length near u64::MAX. The error path overflowed a second way. BDAT is self-delimiting but not self-limiting: a few bytes can describe a list nested a million deep. Dat::from_bytes_limited takes a DecodeLimits stating the greatest depth and buffer length the decoder will accept, and the decoder now splits its frames so that nesting costs about 4 KB of stack per level rather than 139 KB. bdat/limits.rs holds the limits type and feeds the exact hostile bytes back through the decoder as regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uw8f7WyoHpLWFt3wZjtUh

78 days agor1870400018:13538replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Certify the mail hostname and any name a co-hosted daemon serves

The ACME order was built from vhost hostnames alone, so a name Steel did not route could not get into the certificate -- and had no renewal path at all. Nothing reported this. The certificate simply aged out and the service went on answering on it until a client refused the connection, months later. Two names were reachable this way and are now included. An enabled mail block's hostname, because the mail listeners share the HTTPS resolver and would otherwise be rejected on a name mismatch, contradicting the promise already made in MailConfig's documentation. And acme.extra_domains, for a daemon on the same host that terminates TLS for a name Steel does not serve -- an MTA with no ACME client of its own. Steel can issue for any name that resolves to it without having to serve that name. needs_renewal() already reissues when the cached certificate fails to name something in the requested set, so adding either forces a reissue on the next start rather than waiting on the expiry clock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLsXHR6Z83njepi2W1fQZx

79 days agor1870400018:13529replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

12 operations, since the previous mark · compare with the head
+Let an idle O3db server bot sleep instead of spinning a core

An O3db instance that was doing nothing still burned a fifth of a CPU core. The server bot waited on its channel with a one-microsecond timeout, so an idle bot woke about a million times a second only to find nothing and wait again; the default two server bots spun two cores' worth between them. On a downstream gateway sitting idle it showed as a steady ~20% with no work to explain it. The bot now blocks on `recv()`, as the reader, writer and init-gc bots already do. It has no periodic maintenance to wake for -- its only timed work is an external socket path that is entirely commented out -- so there was never a reason to poll. Shutdown is unaffected: it arrives as a Finish message on the same channel, which wakes a blocked `recv()` at once. The one-microsecond constant is deleted rather than left as a landmine for the next bot. A regression test starts a database, lets it fall idle, and asserts it holds under 5% of a core: the old poll measured 23.75%, the blocking recv 0.25%. The measurement needed a self-CPU reader, so `fe2o3_sys` gained `ProcSelf::cpu_ticks` from `/proc/self/stat` -- closing a gap its own module doc already claimed to cover -- with a parser test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cpXabDNEEwbGWTp7WU7ka

79 days agor1870400018:13516replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+Reduce an HTML document to the text a reader would take from it

Two callers want the words on a page and not its markup: a server fetching a page on a user's behalf, and a mail client rendering an HTML part as text. Neither wants a DOM, and neither can assume the input is well formed. `html::html_to_text` is a single pass, not a parser. It drops the tags, drops the elements that hold no prose -- a script, a stylesheet, a navigation bar, a footer -- keeps the ones that do, decodes entities, and collapses the result into lines, returning a `PageText` of the title and the body. Because it recovers nothing and resolves nothing, it answers on malformed input too, where a strict parser would refuse, which is the input a page from the open web usually is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cpXabDNEEwbGWTp7WU7ka

79 days agor1870400018:13504replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Harden the HTTP client to fetch a page on an untrusted party's behalf

The client could talk to a peer it trusts. A gateway fetching a page a user named is the opposite case -- the peer is chosen by whoever wrote the link -- and three holes in the client showed the moment it was pointed at the open web. A caller that resolves a host, checks the address against a public-address rule, and then dials by name resolves twice, and the second answer can differ from the first: DNS rebinding is exactly that trick. `http_request_at` and `https_request_at` take a `SocketAddr` the caller has already vetted and dial that, while `host` stays for the `Host` header and the TLS SNI -- so pinning the address weakens neither the certificate check nor the request. A shared `exchange` helper carries the write-then-read half both schemes have in common. `Transfer-Encoding: chunked` was not decoded at all, though much of the web answers that way, so every such page read as empty. It is decoded now, hex sizes, `;`-extensions and trailing headers included. A hostile chunk-size line of `ffffffffffffffff` once overflowed the add that steps past the chunk's own CRLF and panicked the reader; the add is checked, and an impossible length is the error it is. A caller that sets `max_body_bytes` never reaches that far -- the limit trips first -- but a caller that set none must still not be crashable. A reply to HEAD states the length of a body it will never send, so waiting for those bytes waits forever; the read now takes the peer's close as the complete message it is. The first cut of that fix took *any* short response as complete, which quietly accepted a truncated GET as a whole page -- an empty body is the tell that parts HEAD from a body cut off midway, and only the empty one is waved through. `Url` gains a client's view of an absolute URL, split into scheme, host, port and target: the host is what follows the last `@`, so `https://example.com@127.0.0.1/` resolves to the loopback it names and not the label in front of it. `join` resolves a redirect's four forms, and does not mistake a `://` sitting inside a query for a scheme, nor read a `/` inside a query as a directory boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cpXabDNEEwbGWTp7WU7ka

79 days agor1870400018:13500replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+Rename the jdat binary module to bdat, and say what it is

The binary serialisation of JDAT had no name of its own in the tree: it lived under `binary`, a word that describes a hundred other things and told a reader nothing. It has a name -- BDAT, standing to JDAT as BSON does to JSON -- so the module now carries it, and the two encodings sit as siblings, `string` for the text form an author reads and writes and `bdat` for the binary form that travels on the wire and rests on disk. The module gains a doc header stating what BDAT is and, as importantly, what it is not: a serialisation rather than a container, with no magic number, no version field and no single canonical encoding, so an application wanting a file format supplies those things itself. This is the distinction a caller reaching for a stable on-disk identity has to know before it hashes the bytes. Pure rename and documentation; every call site in the workspace already went through the re-export, so nothing else moves and the whole tree still builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cpXabDNEEwbGWTp7WU7ka

79 days agor1870400018:13482replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Answer an unchanged static request with 304 rather than the whole file

Steel emitted no ETag, no Cache-Control and no Last-Modified, and honoured no conditional request. It could not answer 304 at all, so every asset was re-sent in full on every request: a wasm single-page app shipped its entire bundle on each reload. The absent directive was the other half of the same hole, since a browser left to guess how long it may keep a document will eventually serve a stale application shell. srv/cache.rs derives an entity tag from the file's mtime and size -- what the filesystem already knows, and what changes whenever the file does. A digest of the contents would be a stronger tag, but computing one means reading the whole file on every conditional request, which is the work the tag exists to avoid. The metadata is compared against If-None-Match and the 304 returned before the file is ever opened. An entry document always revalidates: a deploy that changes it is otherwise invisible to anyone still holding the old copy. Other assets are held for server_cfg.static_max_age_secs, which defaults to 0 and so revalidates too -- cheap, because the tag makes an unchanged asset a bodiless 304. Raise it above zero only where filenames carry a content hash. A document rewritten in dev mode to carry the refresh hook is not the file on disk, so it is given no tag at all. Measured against a live app: a reload fell from 327 KB to 5.4 KB on the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YKRWv9DVpjvL3J2q1e6Rd2

79 days agor1870400018:13471replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+Reissue a certificate that does not name every virtual host

Age is not the only thing that can make a certificate useless. One that does not *name* a vhost cannot serve it, however new it is -- so adding a host must force a reissue too. Without the check, a new vhost is served under the old certificate, the name does not match, every browser refuses the connection, and the server reports nothing wrong. This is the second face of the mtime bug fixed in 72693a8: needs_renewal was asking the wrong thing. It now asks the certificate both questions -- when do you expire, and whom do you name. fe2o3_net::tls::certificate_dns_names walks the TBS to the [3] EXPLICIT Extensions, finds OID 2.5.29.17, and collects the dNSName entries. Checked against two live Let's Encrypt certificates, one naming a single host and one naming six: agrees with openssl exactly. Found by adding oxedyne.com beside red.oxedyne.com on the same Steel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13460replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+Renew a certificate on its expiry, not on its file's mtime

AcmeRenewer::needs_renewal compared the certificate file's modification time against a 60-day threshold. That is right only while the file and the certificate share a history, and they come apart the moment a certificate is restored from a backup or copied from another host: the mtime is minutes old, the certificate has weeks left, and the server sails past the expiry serving a dead certificate, never asking why. Splitting red.oxedyne.com onto its own host is exactly that case -- the new machine was handed the old machine's certificate -- which is how this surfaced. So ask the certificate. fe2o3_net::tls gains certificate_not_after, which walks just enough DER to reach Validity.notAfter, handling both the UTCTime a CA uses today and the GeneralizedTime it must use past 2049. A certificate that cannot be read or parsed is treated as expiring: a server that does not know should renew rather than gamble. Checked against a live Let's Encrypt certificate: agrees with openssl to the second. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13450replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+Remove fe2o3_red, and Steel's dependency on it

fe2o3_red was the pre-pivot, server-side Red: an LLM agent whose chat ran over a websocket that Steel served. Red is now browser-only with its own gateway, and the crate here had been a stale copy of it -- a second, older tree that every workspace build still compiled. It also broke a rule: fe2o3 does not name downstream applications. Steel carried a `red_config` vhost block and dispatched /chat to a Red agent handler, which is a library knowing the name of one of its callers. Steel loses the red_config field, its parse, and the /chat websocket dispatch. Nothing else referenced it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13434replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

56 operations, since the previous mark · compare with the head
+fe2o3_net: repair tests that had stopped compiling

The SMTP command test still passed an Fqdn where SmtpCommand::Helo/Ehlo take a String, so the whole integration test binary failed to build. Three doctests used `use crate::...`, which does not resolve from a doctest, or called res! without the prelude. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13377replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+fe2o3_net: vet the addresses a server dials on a user's say-so

A service that opens a connection to a host its user named -- a mail bridge, a webhook sender, a link previewer -- is a request forgery waiting to happen: the user names localhost, or 169.254.169.254, and the server reaches somewhere the user never could. Its position is the privilege, not its credentials. is_publicly_routable refuses loopback, private, link-local, carrier-grade NAT, multicast, broadcast, documentation and reserved space, in both IPv4 and IPv6, including through an IPv4-mapped IPv6 address. resolve_public resolves a name and returns only what may be dialled, failing rather than returning empty when a host resolves entirely into space a server must not reach -- because that is not an empty result, it is an attempt, and the caller wants to say so. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13365replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_net: IMAP4rev1 client

The counterpart to the IMAP server: connect out to a hosted mailbox and pull messages down as raw RFC 5322 bytes. Implicit TLS, STARTTLS or plain; LOGIN or SASL XOAUTH2; CAPABILITY, LIST, SELECT/EXAMINE, UID SEARCH, UID FETCH, UID STORE, APPEND, LOGOUT. The awkward part of IMAP is the wire format, and it is handled once. A response is a line, except when it is a line with a literal spliced into the middle of it -- {1234} followed by that many raw bytes, which may contain CRLF, or an unbalanced parenthesis, or anything else. So the reader assembles a logical line, lifting each literal into a side queue the tokeniser puts back in order. Parsing line by line and hoping no body contains a newline is how one writes a client that works until somebody sends an attachment. Fetching uses BODY.PEEK, so reading a mailbox does not mark it read in whatever client its owner actually uses. UID lists collapse into ranges, because a mailbox synced after a week away yields thousands of consecutive UIDs and a server may reject a command line that long. ImapConfig takes an optional pinned address, so a caller that vets a user-supplied host connects to the address it vetted rather than re-resolving the name. imap::server is now generic over its transport instead of welded to a TLS stream, which is what lets the round-trip test exist: fe2o3_mail's imap_roundtrip drives the real server over a real socket against a real Maildir, including a message whose body carries a CRLF and a stray ')'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13362replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

30 operations, since the previous mark · compare with the head
+fe2o3_net: extract client-side TLS into a shared module

Every outbound protocol client faces the same two problems: it must speak plain TCP and TLS over one socket, because a STARTTLS upgrade happens in place, and it must validate the peer against the host's trust anchors. The SMTP client had both privately. Lift them to fe2o3_net::tls so the IMAP client that follows shares one implementation rather than carrying a copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fqT3gqzMtpYLkuxCrNNfR

80 days agor1870400018:13331replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+fe2o3_steel: resolve {file:}/{env:} in ApiRoute handler config

Split ApiRoute::resolve_headers (which resolved both headers and config) into resolve_headers (headers only, for proxy-mode header injection) and a new resolve_config that expands {file:}/{env:} placeholders in the handler-config map, mirroring WebhookRoute::resolve_config. The vhost loader (app/server.rs) now calls both, so an in-process API handler can read a resolved secret (e.g. a Stripe key referenced as {file:./keys/.../sk}) out of route.get_config, which it previously could not -- the config loop lived only in resolve_headers by accident. Verified: a downstream gateway's /api/checkout handler reads its {file:}-referenced Stripe key and creates a live Checkout Session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lg3ej4xvPwwWtQUYuwFwgK

81 days agor1870400018:13313replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Update README.md

Removed github sponsors text and link.

81 days agor1870400018:13308replica 1870400018

written by h00gs <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+fe2o3_net: ECDSA P-256 signature verification

Add verify_p256_sha256_fixed to fe2o3_net (a thin ring::signature wrapper alongside hmac.rs), verifying an ECDSA P-256 / SHA-256 signature in the exact encodings WebCrypto emits: a 65-byte uncompressed SEC1 public key (0x04 || X || Y), a 64-byte fixed-length r||s (P1363) signature, and a raw (un-pre-hashed) message. Placed in fe2o3_net because it already owns the ring dependency and the ACME P-256 signer; fe2o3_crypto's SignatureScheme uses ed25519-dalek/pqcrypto and has no P-256 backend. This lets a downstream verifier check signatures from browser device keypairs whose WebCrypto lacks Ed25519 and falls back to P-256, uniformly with the existing Ed25519 verify path. Verified: cargo test -p oxedyne_fe2o3_net --lib ecdsa -> 1 passed (ring-generated self-consistent vector; tampered sig/msg/key and wrong-length inputs all rejected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lg3ej4xvPwwWtQUYuwFwgK

81 days agor1870400018:13306replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_net, fe2o3_steel: HMAC-SHA256 + Stripe webhook signature verification

Add HMAC-SHA256 to fe2o3_net (hmac_sha256 / verify_hmac_sha256 built on ring::hmac, constant-time) and verify_stripe_signature to fe2o3_steel::srv::webhook. The latter parses the Stripe-Signature t=/v1= scheme, enforces a +/-300s replay tolerance before any HMAC work, recomputes HMAC-SHA256 over "<t>.<body>", and constant-time compares against every v1 tag so secret rotation is supported. Errors never carry the signing secret. This closes the gap where Stripe webhooks could be acted on without any signature check. ring is already a dependency of fe2o3_net (ACME) and transitively of fe2o3_steel, so no new crate is introduced. Verified: cargo test -p oxedyne_fe2o3_net --lib hmac -> 2 passed (RFC 4231) cargo test -p oxedyne_fe2o3_steel --lib webhook -> 7 passed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lg3ej4xvPwwWtQUYuwFwgK

81 days agor1870400018:13302replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+fe2o3_red: wire brief/fold into the four-panel UI

Makes the brief/fold substrate visible and usable, reusing the shell/CSS. - Rail: the first section is renamed Projects -> Foci and wired to list_foci/create_focus -- a "+" creates a Focus, rows show name + version + updated, selecting one loads its brief. - Centre: a brief view (distinct from a chat) rendering brief.md via the sanitiser, with a steer command line -> steer_brief (the brief agent edits brief.md, streaming tool events to the Agents panel); on turn end the brief re-renders and the rail row refreshes. - Fold: a "Fold in" control runs fold_propose (writes nothing) and shows an LCS line diff of current vs proposed with Accept / Reject. Accept -> fold_apply; Reject discards -- never auto-applied (H2). - Chats coexist unchanged. H5: Focus names via textContent, brief via the render sanitiser, every diff line via createTextNode -- no untrusted innerHTML. Headless proof (rebuilt from source and reproduced by the coordinator): 14/14 assertions PASS, zero console errors -- create/select, steer bumps v0->v1, fold diff shows added lines, Reject leaves the brief and version unchanged, Accept writes the folded brief and bumps past the rejected version, chats coexist. No Rust changed; native unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13292replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_red: brief/fold substrate in the browser

The vision's core: a Focus is a durable OPFS container; its brief is the reduced state; a fold re-reduces a delta into the brief; the log is per-Focus and append-only. Advisory fold only (H2) -- propose a diff, apply on explicit confirm, always retain the raw delta. - src/wasm/focus.rs (new): the Focus OPFS layout (foci/<id>/brief.md, versions/NNNN.md, .red/meta.json, .red/log, .red/deltas/NNNN.md) and the pure store ops -- create, list, read_brief, snapshot, write_brief, record_steer, fold_apply, log_read. Log records carry the full {id,ts,kind,agent,task,parent_brief_version,brief_version,delta_ref,note} schema (kind create|edit|fold); every brief mutation snapshots a version and appends a record. Append is whole-file read-modify-write (single-user/single-Focus safe; the synchronous single-writer WAL is deferred, per D-B6). App-local for now -- a D22 fe2o3_data extraction candidate. - src/wasm/app.rs: the #[wasm_bindgen] surface -- create_focus, list_foci, read_brief, write_brief, log_read, steer_brief (a fresh brief-agent turn, brief inlined + file tools scoped to the Focus, streams events, snapshots+logs an edit only if the brief changed), fold_propose (a fresh tool-less reducer turn over brief+delta, writes nothing) and fold_apply. - src/tools.rs: ToolContext gains path_prefix; on wasm Tool::execute scopes file paths to the Focus dir (empty prefix = passthrough, so native is unaffected and native sites pass String::new()). - llm.rs: extract_json_number is pub(crate) for reuse by the store. Headless proof (rebuilt from source and reproduced by the coordinator): 24/24 assertions PASS -- create/edit/fold snapshots + log ordering, fold_propose writes nothing and adds no record, fold_apply retains and references the raw delta, steer_brief's scoped write lands in foci/<id>/brief.md with no top-level leak; zero console errors. Native: workspace build green, fe2o3_red 59/0 (handler.rs + agent_smoke.rs updated for the new ToolContext field). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13278replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

31 operations, since the previous mark · compare with the head
+fe2o3_red: browser-only four-panel UI on the wasm RedApp

The first fully client-side Red UI: a new www/red.html + www/js/red.js drive the wasm RedApp directly, no server. Reuses the existing four-panel shell, CSS and render.js. - red.js: imports the wasm bundle, holds a RedApp per chat, runs run_turn streaming AgentEvents into the centre (markdown via RedRender.md), renders tool_call/tool_result as collapsible blocks inline and in the Agents panel, and lists the OPFS workspace via run_tool('file_list') with click-to-view (file_read) and delete. Token getters feed the meters. - BYOK settings (base URL, key, model, max tokens, enable-tools) persist to localStorage; RedApp is built from them. Passphrase-wrapping is a documented TODO for the hardening stage. - render.js: adds an H5 sanitiser -- marked output is parsed inertly in a <template> and scrubbed against a tag/attribute whitelist (dangerous elements dropped whole, every on* handler and style stripped, javascript: URLs blocked, rel forced on links) before it reaches the DOM. Every other interpolation (user text, tool names/args/results, file names/paths/contents) uses textContent / escaped lines. - .gitignore: /www/pkg/ (wasm-pack build output). Headless proof (rebuilt from source and reproduced by the coordinator, desktop 1512x900 + mobile 390x844): app loads with the wasm engine, BYOK accepted, assistant text streams into the centre, a file_write tool block shows inline and in Agents, the file appears in the Workspace tree and renders on click; injected onerror/script are stripped and the XSS does not fire; mobile reflows to the bottom nav with no horizontal overflow; zero console errors. Old index.html/app.js (the retired server UI) left untouched. No Rust changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13246replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

10 operations, since the previous mark · compare with the head
+fe2o3_red: complete the browser file toolset over OPFS

Grows the OPFS edge to a full filesystem surface and wires the remaining wasm file tools to it, matching native semantics; only shell still escalates (no in-browser executor). - src/wasm/opfs.rs: split_components (tolerates the root) + jail_components (requires a leaf); descend_dir / open_parent (resolve without creating); read_entries drives FileSystemDirectoryHandle.entries() as a js_sys AsyncIterator, reading each {done,value} record via Reflect (a malformed record is treated as done, so iteration cannot spin), file sizes from getFile().size; plus list_dir, delete_entry (remove_entry_with_options, recursive flag) and exists. - src/tools.rs (wasm arm): file_list/file_edit/file_delete/file_search now route through the OPFS edge with output byte-identical to native -- dirs-first ordering and "name (N bytes)" listing, replacen edit with the same NotFound/Excessive match-count errors, recursive search with the same hidden/target/node_modules skips and rel:line: output. - src/wasm/app.rs: RedApp registers the full file toolset (all but shell) and gains run_tool(name, args) -> String for direct single-tool dispatch (a file-browser panel surface, no LLM turn). - Cargo.toml: web-sys FileSystemRemoveOptions (wasm-scoped). Headless proof (rebuilt from source and reproduced by the coordinator): 13/13 assertions PASS -- write/nested-dirs, list format+sizes, edit + absent-substring error, recursive search hits/exclusions, delete + gone-on-relist + read-deleted errors; zero console errors. Native: workspace build green, fe2o3_red 59/0 unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13235replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_red: agent loop in the browser -- RedApp streaming + OPFS tools

The full agent turn now runs in a browser tab (headless Chromium, no server): chat streams token-by-token, and the agentic tool loop drives OPFS-backed file tools. - src/wasm/app.rs: #[wasm_bindgen] RedApp owns an Agent + Session + ToolRegistry. run_turn(user_msg, on_event) drives Agent::run_turn and forwards each AgentEvent to the JS callback as a structured object (js_sys::Reflect, no JSON re-parse). The constructor parses a full http(s) base URL and toggles the OPFS file tools via enable_tools; prompt/completion-token getters feed the meter. - tools.rs: Tool::execute is cfg-split -- native keeps the std::fs / Executor path verbatim; the wasm path routes file_write/file_read to the async OPFS edge and escalates the rest (Unimplemented) until the edge grows directory iteration/removal (TODO). The native file/shell helpers are native-gated to stay warning-clean. - llm.rs: the wasm transport gains a secure flag + new_with_scheme so wasm_url() honours http/https -- real providers use https, a local mock over 127.0.0.1 uses http for deterministic headless tests. The native transport and the public API are unchanged. - protocol.rs: Session::new / UserConfig::new / generate_session_id called SystemTime::now(), which traps on wasm32; extracted to cfg-split now_secs()/now_millis() helpers (native SystemTime kept byte-identical, wasm uses the core Date.now() shim). Generic -- any wasm caller of the protocol types benefits. - workspace.rs: Workspace::unchecked(root) -- a filesystem-free constructor (native new canonicalises, which traps on wasm); the lexical resolve() jail is unchanged and sound for any backing store. Headless proof (rebuilt from source and reproduced by the coordinator): 10/10 assertions PASS -- streamed tokens in order, assembled text, Done, usage 11/5; tool_call{file_write} -> tool_result -> final text -> done, with the file read back from OPFS as the mock content; zero console errors. Native: workspace build green, fe2o3_red 59/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13221replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

27 operations, since the previous mark · compare with the head
+fe2o3_red: first browser vertical -- wasm-bindgen entry + OPFS edge

fe2o3_red now RUNS in a browser (headless Chromium, no server), retiring the last wasm unknown: gated fe2o3_core executes at runtime, not merely compiles. - src/wasm/entry.rs: the #[wasm_bindgen] surface -- core_probe (F2: exercises getrandom/js, the clock shim and the error machinery), write_file/read_file (async OPFS), and llm_probe (drives the real wasm fetch transport, returns the HTTP status). Async fns surface as Promises via wasm-bindgen-futures; Outcome errors map to a rejected Promise at the boundary only -- the macros are used throughout inside. - src/wasm/opfs.rs: async OPFS edge via navigator.storage.getDirectory, whole-file read/write with parent creation, jailed by the same lexical discipline as Workspace::resolve (absolute and ..-escape rejected). The synchronous single-writer-Worker path (for the .red log) is a documented TODO. - src/wasm/mod.rs: js_str / to_js_err helpers. - llm.rs: split wasm_fetch into wasm_fetch_raw (no status check) + wasm_fetch (keeps the 2xx contract for chat callers, unchanged), and added probe_status() -> Outcome<u16> for the transport probe. - Cargo.toml: crate-type = ["cdylib", "rlib"] (cdylib for wasm-pack, rlib for native linkers); OPFS web-sys features added, wasm-scoped. Headless proof (rebuilt from source and reproduced by the coordinator): core ok with a live rand_u64/clock/err path; OPFS round trip byte-exact ("hello-verify-Ω✓"); llm_probe 401 from Fireworks (readable => fetch + CORS + transport work end to end). Native: workspace build green, fe2o3_red 59/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13193replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_red: compile to wasm32 (browser transport, native edges gated)

Groundwork for browser-only Red: the fe2o3_red library now builds to wasm32-unknown-unknown with the native build and test suite unchanged. - llm.rs: the native tokio+rustls TLS client (open / do_request_full / do_request_stream / LineReader) is native-gated; a wasm transport provides do_request_full / do_request_stream with identical signatures via browser fetch + ReadableStream, so chat_stream / chat_once and agent.rs stay target-agnostic. All JSON/SSE parsing is shared. The wasm constructor drops tls_config -- the browser owns TLS trust. - handler.rs (Steel WS server, sole consumer of fe2o3_net / o3db / SessionStore instantiation) is native-only, gated out on wasm. - executor.rs: Local (std::process) is native-only; a wasm Executor variant returns an Unimplemented escalation error (TODO wasm-exec). - agent.rs: build_tls_client_config is native-gated. - workspace.rs: std::fs compiles on wasm but is unusable at runtime; OPFS is the next edge (TODO wasm-opfs). - Cargo.toml: native-only deps (net, o3db_sync, hash, stds, text, tokio, rustls) and wasm-only deps (wasm-bindgen(-futures), js-sys, web-sys, getrandom js) are target-scoped so neither leaks across targets; portable deps (core, jdat, iop_*, syntax) are shared. Native: workspace build green, fe2o3_red suite green and unchanged. Wasm: cargo build -p oxedyne_fe2o3_red --target wasm32-unknown-unknown green with zero warnings; net and o3db_sync excluded from the wasm graph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13179replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

32 operations, since the previous mark · compare with the head
+fe2o3_jdat: restore corrupted integration tests, fix doctest fence

The four integration test files (string, map, byte, daticle) had been mangled by a botched historical err! refactor (d842b87): dangling delimiters, ErrTag:: prefixes the new macro rejects, and truncated file tails (string lost 339 lines, daticle 426, map 61, byte 13). HEAD carried all of it, so cargo test -p oxedyne_fe2o3_jdat would not compile. Restored each file from e279137 (last good state) and re-applied the intended transforms correctly, confined to tests/: the oxedize->oxedyne rename; the err!(errmsg!(msg), Tags) -> err!(msg; Tags) modernisation (unwrap errmsg!, ',' -> ';' separator, drop ErrTag:: prefixes); and a Test, Invalid tag pair on the tag-less decoder-rejection assertions the new macro requires. Two genuine assertion failures fixed along the way, both in string.rs: the omnibus table's bare true/false rows now decode to Kind::True/False (the decoder gained native-boolean recognition); and the external ./ref/o3db_cfg.dat reference (deleted from the repo) is inlined as a literal so the decode-and-compare assertion still runs. Also removed an orphan doc-comment code fence in src/string/enc.rs that made rustdoc compile the KindScope table's trailing prose as Rust. cargo test -p oxedyne_fe2o3_jdat now green: lib tests, the integration harness (main.rs), and doctests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13146replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+Cargo.lock: record fe2o3_core wasm target dependencies

Follows 682c147 (wasm32 runtime support): locks getrandom, js-sys, wasm-bindgen and web-sys as fe2o3_core's wasm32 target dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13122replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+fe2o3_core, fe2o3_iop_db: wasm32 runtime support

Groundwork for a browser-only fe2o3_red: make the two crates run (not just compile) on wasm32-unknown-unknown, native behaviour unchanged. fe2o3_core: - New wasm module: a Date.now() millisecond clock and a console.log emitter (js-sys / web-sys), target-scoped so getrandom's js backend and the wasm-bindgen deps never leak onto native via Cargo feature unification. - time.rs: Timer keeps an Instant natively, a Date.now() f64 on wasm; wait_for_true returns an Unimplemented error on wasm (a browser thread cannot block) rather than spinning the event loop. - log: the threaded LogBot/console path and thread::spawn are native only; the wasm Logger is single-threaded (no SharedArrayBuffer / COOP-COEP) and formats + emits straight to the browser console in send_in. bot.rs reconstructs the timestamp from Date.now() where no SystemTime exists. fe2o3_iop_db: - The concrete EncryptionScheme lives in fe2o3_crypto, which links a C archive and cannot target wasm. It fills only the default-scheme slot of RestSchemesOverride (needs Clone + Debug, never constructed here), so the crypto dep is target-gated native-only and wasm substitutes an uninhabited placeholder enum. The public type shape is identical on both targets; a browser build simply cannot select the built-in at-rest scheme. Native: workspace build green, core lib tests 8/8, iop_db green. Wasm: core, iop_db, jdat, syntax, hash, stds, text all build to wasm32-unknown-unknown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13119replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+fe2o3_core: fix stale err! tag-separator syntax in tests

Three test lines used the old comma form err!(msg, Tag, ...); the macro separates the message from its tags with a semicolon: err!(msg; Tag, ...). They blocked the core lib-test binary from compiling. Pre-existing, unrelated to any feature work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13090replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+Red: browser-only pivot -- brief/fold architecture, hardening, Slice 0

Records this session's design arc. - brief/fold architecture (Foci, brief, re-taskable agents, fold reducer, per-Focus append-only log, direct/conducted): settled vocabulary threaded through README, plan_v1 (0.1, D16-D22) and TODO. - Hardening / non-functional requirements (H1-H5) from a sceptical review: run-user isolation, advisory fold, jdat/net for the LLM transport, WAL / single-writer log, escape-by-default frontend. - Browser-only track (proposal_browser_only.md): local-first, BYOK, wasm execution; port the core, swap three edges; native fe2o3 severed by the iop_* trait seam, not ported. - Slice 0 (slice_0.md): measured de-risk results -- CORS on all five providers, OPFS survives restart, the full agent loop runs in-browser, and the wasm leaf set compiles after the two portability fixes above. Decision: Red goes browser-only; server-native Red retired; native fe2o3 and Steel untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:13086replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

147 operations, since the previous mark · compare with the head
+fe2o3_text, fe2o3_jdat: fix 32-bit usize overflow (wasm portability)

Two genuine 32-bit-usize bugs that stopped fe2o3 building on any 32-bit target, wasm32 included; surfaced by the Red browser-only wasm-port audit (Slice 0). - fe2o3_text base2x: MAX_A = 2^32 overflows a 32-bit usize. Widen the alphabet-size ceiling to u64 (lossless on 64-bit) and cast the usize argument at the comparison site. - fe2o3_jdat c64_len: the upper length literals exceed a 32-bit usize. Widen the argument to u64 once before the comparison ladder. Minimal and lossless on 64-bit. Native builds unaffected; fe2o3_text and fe2o3_jdat lib tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXabhfm8UjGpaJupngddPE

82 days agor1870400018:12938replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

4 operations, since the previous mark · compare with the head
+Red: UI tidy — adaptive panel reflow, denoise chat + tool blocks

Driven by a headless review loop (screenshot + DOM measurement) to catch layout problems before they reach the browser. - Adaptive panel reflow: side panels now auto-collapse (workspace first, then agents) when the window is too narrow to fit them plus a usable centre, and restore when it widens. Fixes the workspace spilling off the right edge below ~1050px. Verified: zero horizontal overflow at every width 900-1920. - Fixed the real 'bottom cropping': the long input placeholder wrapped to a second line and clipped in the single-row textarea once the centre dropped below ~450px. Shortened to 'Type a message…' — no wrap at any width. - Fixed a reflow bug where panels were measured while #app was still display:none (clientWidth 0), wrongly hiding agents+workspace at full width; reflow now skips until laid out and re-runs on show. - Removed the redundant model label from chat boxes and agent tiles (already in the session name). - Tool-call blocks collapse by default (caret affordance), hiding the often-long args JSON while keeping the result visible. - Distinct upload icon in the workspace header (was a second up-arrow). - Top-bar total labelled '$X total' when no session is active. Regression: drag-resize, close/reopen, agent turn, files, and all four mobile panels pass headlessly with no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12933replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

30 operations, since the previous mark · compare with the head
+fe2o3_data, fe2o3_o3db_sync: fix stale doctests

The DiGraph doctests were written against an older API and never updated: they used u32/String/() as the ID/ND/LD type parameters, but NodeId/NodeData/LinkData are marker traits with concrete-type impls (NodeData/LinkData now also require Display), so u32/String/() never satisfied the bounds. Rewrote all seven to define newtypes that implement the markers -- the new() example shows the pattern in full, the rest hide the boilerplate behind rustdoc # lines. The three fe2o3_o3db_sync crate-overview examples are illustrative sketches over the heavily-generic stateful DB API, with undefined variables (user_id) and cross-example state; marked ignore, matching the convention the crate already uses for its db.rs / bot_writer.rs examples. cargo test -p oxedyne_fe2o3_data and -p oxedyne_fe2o3_o3db_sync (with and without dist) now pass with zero failing doctests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12902replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: absorb the kademlia and oam primitives as dist modules

The Kademlia routing table (fe2o3_kademlia) and the Oxegen Allocation Mechanism (fe2o3_oam) were standalone crates consumed only by the distributed Ozone layer. Following the same reasoning that folded the scaffolded fe2o3_o3db_dist into fe2o3_o3db_sync::dist -- distributed mode is a mode of the existing engine, not a separate crate tree -- they become in-crate modules behind the dist feature. - kademlia -> fe2o3_o3db_sync::kademlia (internal crate:: refs -> super::). - oam -> fe2o3_o3db_sync::oam (kademlia refs -> crate::kademlia, self re-export of OamConfig/Threshold). - Both gated by #[cfg(feature = "dist")]; the dist feature no longer pulls optional deps, it only gates module compilation. - dist consumers use crate::{kademlia,oam}; moved integration tests gain #![cfg(feature = "dist")]. - Both crates deleted. Full dist suite green: engine 31, hotstuff 18, oam_placement 21, kademlia_routing 13, anti_entropy 14, cohort 12, consensus 11, plus the kademlia/oam module doctests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12882replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

63 operations, since the previous mark · compare with the head
+fe2o3_data: absorb the iblt and hll primitives as modules

The invertible Bloom lookup table (fe2o3_iblt) and the HyperLogLog cardinality sketch (fe2o3_hll) were standalone core-only crates whose sole consumer was fe2o3_o3db_sync. They are general-purpose probabilistic data structures, so they belong beside DiGraph, ring, stack and tree in the data crate, where any Hematite crate can reach them without a dependency on the database. - iblt -> fe2o3_data::iblt (impl in imp.rs, private hash submodule, public types re-exported at the module root). - hll -> fe2o3_data::hll (sketch submodule, public types re-exported). - Integration tests move to fe2o3_data/tests/{iblt,hll}.rs. - Both crates deleted; fe2o3_o3db_sync drops the two optional deps and imports fe2o3_data::{iblt,hll} instead. 14 iblt + 15 hll integration tests and both module doctests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12818replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+Red: four-panel desktop UI with pullable widths + mobile panel nav

Restructure the web UI into the doc/red mockup layout: rail (projects + chats), center (the conversation), agents (live runs), and workspace (file tree), on the mockup's warm palette in both themes. - Side panel widths are user-pullable via drag handles (pointer capture, clamped, persisted, double-click resets to default). - The agents and workspace panels close/open via header buttons and topbar toggles, with animated width. - The agents panel shows each turn of the active chat as a run tile: status pill, live tool rows (click scrolls to the inline block), context bar, and cost. - Session/fleet meters sit in the top bar and center header. - The file browser is docked as the workspace panel instead of a fixed overlay; same fs_* protocol. - Mobile (<=760px) shows one panel at a time via a bottom nav. Verified headlessly (~/.red-pw/ui4.js) against the isolated instance: resize, close/open, agent turn with tools, file view, and all four mobile panels pass with no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12789replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

152 operations, since the previous mark · compare with the head
+Red: pivot brief and reference mockup (doc/red)

The executor brief for the Red pivot: brief/run/fold model over an append-only event log, four-panel layout, agent lifecycle and metering, teamwork layer, data model, and open decisions. The accompanying index.html + assets is a clickable reference mockup of the target layout and interactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QKsgr1o7XCyCNjxPqKc7v

82 days agor1870400018:12636replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

8 operations, since the previous mark · compare with the head
+Red: UX fixes from live testing + hex-safe file upload

Verified end-to-end via a headless-browser harness (isolated local instance) — all five confirmed: - Upload bug: fs_write now hex-encodes content (client) and decodes it (server), so files with newlines/quotes/$ and no extension no longer break the WS syntax parser. Fixes the reported msg.rs:615 error. - Tool/process blocks no longer vanish when clicking the already-active session (clicking the active session is now a no-op). - Explicit show/hide toggle for agent process blocks (sidebar wrench). - File browser auto-refreshes after a turn, so agent-written files appear. - File viewer shows line numbers (default on) with a # toggle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12627replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+Red: plan_v1.md — v1 status (WS-A/C/D/E/F/G/I + rendering done)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12604replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Red: use the session's selected model (make the model picker functional)

The agent used the configured default model regardless of the session's choice; now each turn uses session.model (set by the new-session picker), falling back to the default. The first, high-value slice of WS-B; multi-provider/BYOK config remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12602replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Red: WS-E skills, WS-F file browser, WS-I mobile, rich rendering

Built in parallel (three subagents) then integrated. All client JS syntax-checked; crate builds; 59 unit tests pass (18 new in skills). Skills (WS-E) — src/skills.rs: - Skill loader for .red/skills/<name>.md (--- frontmatter + body); a tolerant `<name args>` … optional `</name>`/`</>` invocation parser (only the opening tag is >-terminated, so `>` inside the body survives; missing >/close recover gracefully); expand() injects a matched skill's instructions. Wired into the chat handler before the turn. 18 unit tests. File browser (WS-F) — www/js/files.js + css/files.css + fs_* protocol: - New Red WS commands fs_list/fs_read/fs_delete/fs_write with fs_tree/ fs_content responses, all confined to the per-user workspace via Workspace::resolve. A slide-in file panel: browse, view (text), delete, upload (FileReader→fs_write), download (blob). Opened from a sidebar Files button. Mobile (WS-I) — www/css/responsive.css (+hamburger): - ≤760px: sidebar becomes an off-canvas drawer with overlay + hamburger, full-width chat, internal scroll for code/tool blocks, keyboard-safe input. Desktop untouched. Rich rendering — www/js/render.js + css/render.css: - RedRender.md wraps marked and adds a dependency-free syntax highlighter (js/ts/rust/python/bash/json) + per-block Copy button, themed for light/dark. Assistant messages now render through it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12600replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+Red: plan_v1.md progress status (WS-A/C/D/G done)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12571replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+Red: WS-G — live context-window meter

Track the current context size distinctly from cumulative billing: - Session gains last_prompt_tokens (prompt tokens of the most recent request = current context usage), set by the agent each turn in both the streaming and tool-loop paths; serialised in session metadata. - Client: per-model context windows (GLM-5.2 = 1M, etc.), a fmtCtx helper, and a live meter (bar + "used / limit") in each session box, turning red past 85%. Cumulative prompt/completion tokens remain for cost. Richer cost (per-token-class, pre-send prediction, cache-hit) stays a fast-follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12569replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+Red: WS-C/WS-D — workspace, executor, and agentic tool loop

The coding half of Red: the agent can now read/write/edit/list/search/ delete files and run shell commands in a per-user sandboxed workspace, driven by LLM tool-calling. Verified end-to-end against live GLM-5.2 (examples/agent_smoke.rs): the model writes a file and reads it back. New modules: - workspace.rs: per-user Workspace with path-jailed resolve() (accident guardrail for the trusted env, not an attack boundary). Rejects absolute paths and ../ escapes; built lexically. - executor.rs: Executor enum (D4). Local variant runs `sh -c` in a cwd with a timeout and kill-on-drop. A run location, not a sandbox. - tools.rs: Tool enum (file_read/write/edit/list/search/delete, shell) — concrete types over dyn dispatch. ToolContext (workspace+executor+ cwd), ToolRegistry (definitions_json for the API, dispatch by name). LLM tool protocol (llm.rs): - chat_once: non-streaming request+response carrying tool_calls, used by the tool loop (complete tool_calls parse more reliably than streamed fragments). Streaming preserved for the pure-chat path. - build_body with tools + tool_choice; message_to_json serialising assistant tool_calls and the tool role (which datmap_to_json omits). - parse_full_response + find_json_array + split_top_level_objects. - Fix: extract_json_string / find_json_object / find_json_array now tolerate whitespace after the colon (`"key": "value"`), which real model output uses — caught by the end-to-end smoke test. Values now decode as UTF-8. Regression tests added. Agent (agent.rs): - run_turn takes a ToolRegistry. run_tool_loop drives non-streaming rounds, executing tool calls and emitting ToolCall/ToolResult events until a final answer (capped at 25 rounds). Only the user turn and final assistant text are persisted; the tool exchange stays in the working conversation. Protocol/handler/syntax: - ChatMessage::Assistant carries tool_calls; ToolCall type; AgentEvent ToolCall/ToolResult; tool_call/tool_result syntax commands. - Handler builds a per-user registry (<workspace_root>/<user>) each turn. - RedConfig gains workspace_root; RedState gains workspace_base+executor; Steel https.rs resolves the base under the app root. Client (app.js/app.css): - Multi-value command parser; collapsible inline tool-call blocks (name, args, result). 41 unit tests pass; fe2o3_steel builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12550replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

86 operations, since the previous mark · compare with the head
+Red: WS-A MVP hardening

Server: - max_tokens on every LLM request (RedConfig field, default 4096; threaded LlmClient::new -> build_request_body). Caps GLM-5.2 runaway reasoning loops. Verified honoured against Fireworks. - Downgrade per-message/verbose handler logs (received-text, command, handshake, text_msg failures) from info! to debug!. Client (app.js/index.html/app.css): - WebSocket auto-reconnect with backoff; restores the active session's history on reconnect. - Stop button: the send button becomes a stop control during a turn; clicking closes the chat WS (aborting the in-flight LLM stream server-side) and reconnects. - Empty/welcome state when no session is active. - Relative session timestamps in the sidebar. - Favicon (Oxedyne red flame); fixes the 404. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12463replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

36 operations, since the previous mark · compare with the head
+Red: v1 plan (plan_v1.md)

Interview-driven v1 plan: north star (dissolve Claude Code / web chat), open-source self-hosted trusted-env positioning, decision log D0-D15, workstreams A-J. Supersedes doc/plan.md (kept for history). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12426replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Red: conform to error-handling style, fix stale tests

Bring the crate into line with the fe2o3 error-handling rules and repair unit tests left broken by the streaming rewrite. Error handling: - Replace `?` propagation with res! in session.rs (create/list/ delete session) and llm.rs (request write/flush). - Replace .unwrap()/.expect() on Options with explicit match: handler.rs command dispatch and session take (both continue the message loop, guarded above), llm.rs chunk_remaining. - get_username Option short-circuits (`?`) rewritten as match. - LineReader stays in std::io::Result (a deliberate byte-level boundary): fill_buf call and chunk_remaining guard use explicit match / io::Error rather than res!/err!. Tests: - parse_sse_stream now returns (String, u64, u64); destructure the tuple in the two SSE tests. - Remove test_dechunk / test_dechunk_multiple — dechunk was folded into LineReader and no longer exists. Tidy: - Drop redundant `if is_chunked { None } else { None }`. All 21 unit tests pass; fe2o3_steel still builds against the crate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011PRPt6tSS4DKfAGJssfF1g

83 days agor1870400018:12423replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+Red: true incremental SSE streaming

Replace the buffered LLM response approach with true line-by-line streaming. Text tokens now appear in the browser as they arrive from the LLM, not after the full response is buffered. LLM client (llm.rs): - New do_request_stream method replaces do_request. Reads response headers byte-by-byte, then streams the body line-by-line using a new LineReader struct that handles HTTP chunked transfer encoding transparently. - Each data: line is parsed immediately — on_token is called as content arrives, not after the full response is buffered. - LineReader handles both chunked and identity transfer encoding. For chunked, parses chunk size headers and tracks remaining bytes per chunk. - Removed old do_request (buffered) and dechunk function. - parse_sse_stream kept for unit tests. Handler (handler.rs): - Agent events are now sent via mpsc channel + tokio::select! instead of being collected into a Vec and sent after the agent turn completes. The agent future is pinned and polled concurrently with WS message sending — events flow to the browser as the LLM streams. - event_to_ws helper function extracts command name and values from AgentEvent. - Session is taken out of current_session for the duration of the agent turn (avoids borrow conflicts with the pinned future), then put back and saved. Frontend (app.js): - Fix \n escape handling in JS syntax parser: add .replace for \n and \t in quoted string values. - Set marked.setOptions({ breaks: true }) so single newlines render as <br> in markdown output.

83 days agor1870400018:12394replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

30 operations, since the previous mark · compare with the head
+Red: add TODO.md with categorised and prioritised functionality list

10 categories covering MVP polish, multi-provider support, billing, tools/sandbox, file I/O, context management, UI/UX, security, extensibility, and testing. Each item tagged P0/P1/P2. Key P0 items: true incremental SSE streaming, max_tokens limit, stop/abort button, WebSocket reconnection.

83 days agor1870400018:12363replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

2 operations, since the previous mark · compare with the head
+Red: fix header title on rename, add loading spinner

1. Session rename updates header title immediately when the renamed session is the active one. 2. Three-dot bouncing spinner shown in chat output while waiting for LLM response. Removed on done/error.

83 days agor1870400018:12360replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+Red: token tracking, billing, settings modal, rename fix

1. Fix session rename focus loss: click on session name was bubbling to the box click handler, triggering session_switch whose data response matched the session_new check (obj.id && obj.model), causing refreshSessions() to re-render the list and destroy the input. Fixed by: (a) stopPropagation on name click, (b) reorder data handler to check obj.messages before obj.id && obj.model. 2. Token usage tracking: add stream_options include_usage to LLM request, parse prompt_tokens and completion_tokens from the final SSE chunk. Store cumulative counts in Session metadata. Display token count and cost estimate in session boxes. 3. Cost estimation: pricing table for Fireworks models (USD per 1M tokens). Cost shown in session box alongside token count. 4. Settings modal: add Cloud Provider dropdown (Fireworks.ai active, OpenAI/Anthropic/Google/Groq listed as coming soon). Existing change password section preserved. 5. LLM client: combine request headers and body into single write to avoid separate TLS records (some CDN servers reject this). Add debug logging to text_msg for serialisation failures. 6. refreshSessions() called after done event so session box updates with accumulated token counts after each turn.

83 days agor1870400018:12353replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

36 operations, since the previous mark · compare with the head
+Red: UI improvements — model picker, session boxes, data JSON fix

1. New Session flow: + New Session button now prepends a panel to the session list with a model dropdown (GLM-5.2, GLM-5.1, GPT-OSS 120B, DeepSeek V4 Pro, Kimi K2.6) and a Start button. Clicking Start sends session_new with the selected model and an auto-generated name (model label + timestamp). 2. Session boxes: each session in the sidebar is now a box showing the session name (click to rename inline), model label, context token estimate, and an archive × button. 3. Fix data responses: session_list and session_switch were sending Dat::Map values which serialize as JDAT, not JSON. The JS parser expects JSON. Now uses datmap_to_json to send JSON strings, matching session_new which already did this. 4. Add session_rename handler (syntax was defined in previous commit but handler was missing). 5. Add debug logging to text_msg for diagnosing serialisation failures. 6. Fix attemptRegister promise chain (was catching on wrong then).

83 days agor1870400018:12316replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

43 operations, since the previous mark · compare with the head
+Red: fix chat WS, decouple syntax from Steel, fix SSE parser

Three fixes and one refactor to get Red's /chat WebSocket working end-to-end: 1. Fix app.js race condition: refreshSessions() was called before the chat WS was open. Move it into the onopen handler. 2. Fix TLS/HTTP issues in LLM client: - Add ALPN http/1.1 protocol advertisement so CDN-fronted servers (Fireworks.ai/Cloudflare) don't close the connection after the TLS handshake. - Treat UnexpectedEof as graceful close — many HTTP servers close TCP without a TLS close_notify when using Connection: close. - Combine request headers and body into a single write to avoid separate TLS records. 3. Fix SSE parser: extract_json_string matched content inside reasoning_content, causing the model's internal reasoning (with literal \n\n) to be streamed to the client. Now verifies the character before the key is { or , to ensure a complete JSON key match. 4. Decouple Red's syntax from Steel: - New fe2o3_red/src/syntax.rs defines all Red chat protocol commands (session_new, session_list, session_switch, session_close, session_rename, chat, text, done, data, info, error). - RedState no longer takes a SyntaxRef from Steel — it builds its own in handle_chat_websocket. - Steel's syntax.rs no longer defines Red commands. - https.rs no longer passes vhost.ws_syntax to RedState. 5. Add session_rename command and handler. 6. Fix handler to send Dat::Str values instead of Dat::Map for agent events (text/done/error), matching the syntax definitions.

83 days agor1870400018:12272replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

57 operations, since the previous mark · compare with the head
+fe2o3_red: Phase 1 — AI agent and web chatbot (MVP).

New crate fe2o3_red providing a web-based AI agent chat interface, replacing the tmux/PTY terminal bridge approach. The agent talks directly to an OpenAI-compatible LLM API (Fireworks GLM-5.2) via SSE streaming, with no terminal emulation, no Goose CLI, no tmux. ## Crate structure fe2o3_red/ src/ lib.rs — crate root protocol.rs — ChatMessage, Session, UserConfig, AgentEvent types with JDAT serialisation (no serde) llm.rs — LlmClient: HTTPS POST to LLM API, SSE parsing, JSON body building via datmap_to_json (no serde) session.rs — SessionStore: O3db-backed CRUD for sessions and user config, multi-user from the start agent.rs — Agent: run_turn() drives the agent loop (message → LLM → streamed response → session) handler.rs — RedConfig + RedState + handle_chat_websocket: path-based WS handler at /chat for Steel www/ index.html — chat UI (login, sidebar, chat area, settings) css/ — dark/light theme, draggable sidebar resize js/ app.js — chat logic, WS protocol, markdown rendering o3db.js — Steel WS auth client (reused from elearnity) marked.min.js — markdown renderer (bundled) doc/ plan.md — full design document with 5 phases ## Steel integration - RedConfig added to VhostConfig (llm_host, llm_port, llm_path, llm_key, llm_model, system_prompt) - /chat WS path dispatch in https.rs: when vhost has red_config, WS upgrades to /chat route to handle_chat_websocket - RedState holds Agent + SessionStore + SyntaxRef - Reuses Steel's existing session cookie auth (login/register/ whoami/change_pass) - O3db database for per-user session storage ## Design decisions - JDAT over WS: uses fe2o3_jdat for all serialisation, no serde - Multi-user: SessionStore keyed by username, UserConfig per user (foundation for commercial offering with billing) - No PTY, no tmux: clean JSON protocol over WebSocket - SSE streaming: LLM response tokens stream to browser as they arrive (though Phase 1 reads full response then parses — true incremental streaming is a future optimisation) - Eat your own dog food: fe2o3_net for HTTP, fe2o3_jdat for serialisation, fe2o3_o3db_sync for storage ## Tests 23 unit tests (protocol roundtrips, SSE parsing, JSON building, chunked encoding dechunking, session ID generation, O3db key formats). 12 Steel tests still pass. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

83 days agor1870400018:12214replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

46 operations, since the previous mark · compare with the head
+fe2o3_steel: spawn per-connection processing in accept loop.

The accept loop was performing stream.peek(), TLS handshake (tls_acceptor.accept), and plaintext redirect handling inline. If a client connected but didn't send data (scanner, broken connection, slow client), stream.peek() or tls_acceptor.accept() would block indefinitely, preventing the accept loop from accepting new connections. This caused Steel to become unresponsive on karri every few hours — the tokio runtime and O3db health checks continued running (separate tasks), but no new HTTPS connections could be accepted until the blocking operation completed or Steel was restarted. The fix spawns the entire per-connection processing (peek + TLS handshake + handler, or peek + redirect) into its own tokio task immediately after listener.accept() returns. The accept loop now goes straight back to accepting the next connection without waiting for any per-client work. 12/12 tests pass, no warnings. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

83 days agor1870400018:12167replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

7 operations, since the previous mark · compare with the head
+fe2o3_steel: PTY-based terminal bridge for tmux attach.

The previous pipe-based approach failed because tmux attach requires a real terminal (PTY) for stdin/stdout — pipes cause it to exit immediately with "open terminal failed: not a terminal". This commit rewrites handle_terminal_websocket to: 1. Create a PTY pair via nix::pty::openpty (24×80 initial size). 2. Dup the slave FD three times using nix::unistd::dup for the child's stdin, stdout, and stderr (Stdio::from(OwnedFd) — safe, no unsafe blocks needed despite the crate's forbid(unsafe_code)). 3. Spawn "tmux attach -t <session>" with the slave PTY as its stdio, so tmux sees a real terminal and stays connected. 4. Set the master FD to non-blocking via nix::fcntl. 5. Wrap the master in tokio::io::unix::AsyncFd for async readiness notifications. 6. Bridge bidirectionally: WS binary → master write, master read → WS binary. WouldBlock on writes is silently dropped (keystroke loss is acceptable); WouldBlock on reads clears readiness and retries. No pre_exec/setsid — tmux attach works with just the PTY slave as stdio without creating a new session. This keeps the code safe (no unsafe blocks) and simpler. Dependencies: nix 0.31 with "term" and "fs" features (openpty, dup, fcntl). libc dependency removed (not needed — nix provides safe wrappers for all required syscalls). 12/12 tests pass, no warnings. Playwright-verified: WS stays open, terminal output (Goose ASCII art) renders in xterm.js, keystrokes flow through to Goose, Goose responses stream back to the browser. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

84 days agor1870400018:12159replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

54 operations, since the previous mark · compare with the head
+fe2o3_steel: terminal session management with tmux and WS I/O bridge.

Adds terminal session management to Steel's WebSocket handler, enabling web-based terminal applications (e.g. a Goose agent UI) served directly through Steel without external tools like ttyd. ## TerminalManager (srv/ws/term.rs) New module providing: - TerminalManager: creates, lists, closes and renames tmux sessions via the tmux CLI. Session names use a configurable prefix (e.g. "goose-1", "goose-2"). - handle_terminal_websocket: an async function that bridges a WebSocket binary channel to a tmux attach child process. Spawns "tmux attach -t <session>" with piped stdin/stdout and bidirectionally pipes bytes: client keystrokes → tmux stdin, tmux stdout → binary WS frames. No PTY management needed — tmux handles the PTY for the child process; we only bridge bytes. The terminal I/O bridge uses a separate WS path (/term/<session>) to keep binary terminal data separate from the text-based syntax protocol on the management WS. The tmux session persists across WS disconnects; reconnecting reattaches. ## Syntax commands (srv/ws/syntax.rs) Five new WS syntax commands: - term_new: create a new tmux session, returns session name. - term_list: list active sessions as a JDAT map. - term_close: kill a session by name. - term_set_name: rename a session. - change_pass: change the authenticated user's passphrase, verifying the old passphrase via Argon2id before writing the new hash. ## Command handlers (srv/ws/handler.rs) AppWebSocketHandler gains an optional TerminalManager field, attached per-vhost at server start-up via with_term_manager(). The handle_text dispatcher gains arms for all five new commands. change_pass reuses the existing KDF infrastructure (Argon2id) and preserves the user record's created_at timestamp. ## Path-based WS dispatch (srv/https.rs) The WebSocket upgrade branch now checks for a /term/ path prefix before routing to the normal text-protocol handler. Matching requests are forwarded to handle_terminal_websocket with the session name extracted from the URL path. Non-matching upgrades fall through to the existing handler unchanged. The proxy-route WS check (added in the previous commit) remains before this terminal check, so proxy routes take precedence on vhosts that have both. ## TermConfig (srv/cfg.rs) New TermConfig struct with session_prefix and launch_command fields, parsed from JDAT via from_datmap. Added to VhostConfig as an optional term_config field with Default = None and from_datmap parsing. ## VhostRuntime (srv/context.rs) VhostRuntime gains an optional term_manager field (currently unused — the TerminalManager is attached to the WS handler at construction in server.rs, not stored separately in the runtime). Reserved for future use cases that need terminal access outside the WS handler. ## Server wiring (app/server.rs) When constructing each VhostRuntime, if the vhost's VhostConfig has a term_config, a TerminalManager is created and attached to the cloned AppWebSocketHandler via with_term_manager(). Vhosts without term_config get the plain handler (term_* commands reject with "terminal features not enabled"). ## Tests Test file updated for new VhostConfig and VhostRuntime fields. 12/12 lib tests pass, no warnings. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

84 days agor1870400018:12104replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+fe2o3_steel: proxy WebSocket upgrades before Steel's own WS handler.

When a vhost has proxy_routes configured, WebSocket upgrade requests matching a proxy route prefix must be tunnelled to the upstream rather than intercepted by Steel's built-in WebSocket handler. The previous code checked is_websocket_upgrade() unconditionally at the top of the request pipeline, before proxy routes were evaluated — so any WebSocket upgrade on a proxied vhost was handled by Steel's own WS handler instead of being forwarded. This commit adds a proxy-route check inside the WebSocket upgrade branch: if a ProxyRoute matches the request path, the upgrade is tunnelled to the upstream via handle_proxy_websocket. Non-matching upgrades fall through to Steel's own WS handler as before. The duplicate WS check inside the HTTP proxy dispatch block (in the else branch after redirects) is now unreachable for proxied WS requests but retained as a safety net. 33 lines added, no existing code modified. 12/12 tests pass. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

84 days agor1870400018:12080replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+fe2o3_steel: ProxyRoute reverse proxy with WebSocket tunneling and streaming.

Steel's existing ApiRoute forwards a single exact-matched path to an upstream API and buffers the complete response before returning it. That design suits third-party API calls (payment gateways, webhook targets) but cannot reverse-proxy a full web application: it lacks prefix matching, WebSocket upgrade support, and response streaming, all of which are required for modern SPA front-ends that use SSE or WebSocket for real-time communication (e.g. streaming chat responses). This commit adds a new ProxyRoute type alongside ApiRoute, with a separate dispatch path in the HTTPS handler that runs after redirect rules but before static file serving and API routes. When multiple proxy routes match a request path, the longest prefix wins. ## ProxyRoute (srv/cfg.rs) New struct with five fields: - path_prefix: string matched with starts_with (e.g. "/" catches everything, "/api/" catches API subtree). - upstream_host: target hostname or IP (e.g. "127.0.0.1"). - upstream_port: target TCP port. - upstream_tls: whether to open a TLS connection to the upstream (default false — loopback proxies typically don't need it). - strip_prefix: when true, the matched prefix is removed from the forwarded path (e.g. "/chat/api/v1/users" with prefix "/chat" becomes "/api/v1/users"). When false, the full original path is forwarded verbatim. Parsed from JDAT via from_datmap with the same error-reporting style as ApiRoute and WebhookRoute. Added to VhostConfig as an optional proxy_routes: Vec<ProxyRoute> field with Default::new(), from_datmap parsing, and threaded into VhostRuntime at server start-up. ## Dispatch (srv/https.rs) The proxy check is inserted in the else branch of handle_https, after redirect rules have been evaluated and before the request reaches the static-file / API-route / web-handler pipeline. If a matching ProxyRoute is found: - WebSocket upgrade requests are tunnelled: Steel connects to the upstream, forwards the upgrade handshake (including Sec-WebSocket-Key, Sec-WebSocket-Version, etc.), forwards the upstream's 101 response to the client, then bidirectionally pipes raw bytes via tokio::io::copy in a tokio::select. The tunnel stays open until either direction closes. - Regular HTTP requests are streamed: Steel connects to the upstream, writes the request with Connection: close, then reads the response in 16 KiB chunks — headers are parsed for the status code (for traffic recording) and forwarded immediately, body chunks are forwarded as they arrive without buffering. This preserves SSE and chunked-transfer semantics. X-Forwarded-For and X-Forwarded-Proto headers are injected on both paths. Hop-by-hop headers (Host, Connection, Content-Length, Transfer-Encoding) are stripped and managed by the proxy. ## VhostRuntime (srv/context.rs) VhostRuntime gains a proxy_routes: Vec<ProxyRoute> field, cloned from VhostConfig at start-up in app/server.rs alongside the existing redirects field. ## Tests (tests/server.rs) VhostConfig and VhostRuntime test constructors updated for the new field. AppWebHandler::new call updated for the full argument list (api_routes, webhook_routes, registries, tls_client, admin_state, traffic) that was added in earlier commits but not yet reflected in the test. ServerContext::new call updated for traffic recorder and admin state arguments. All 12 lib tests pass. Co-Authored-By: GLM-5.2 <noreply@anthropic.com>

84 days agor1870400018:12078replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+fe2o3_text: Annealer idempotency fixes (100/100 corpus) + multi-language support.

Fixes the three remaining corpus failures (proc_macro2, rg_search, rustc_hir) by adding colon/path separator protection, depth-aware brace emission, struct-in-enum field formatting, and //// doc-comment rejection. Adds multi-language formatting pipeline (C, C++, C#, Go, Java, JS/TS, Python) with heuristic language detection. Includes detect_corpus test data and diagnostic harness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

141 days agor1870400018:12058replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

183 operations, since the previous mark · compare with the head
+fe2o3_text: Annealer CLI binary, idempotency fixes, 100-file test corpus.

New `annealer` binary (src/bin/annealer.rs) with --write, --check, --stdin, --lang, --config, and recursive directory walk. Formatter fixes: context-aware spacing preserves `E: ::std` bound syntax; `inside_macro` flag prevents field-alignment inside macro bodies (bitflags!, etc.); empty trailing groups filtered from comma-split. 97/100 corpus idempotency, 0 crashes. Test corpus: 100 Rust files from 40+ GitHub projects (143k lines). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

141 days agor1870400018:11874replica 1870400018

written by h00gs <hello@oxedize.com>

230 operations, since the previous mark · compare with the head
+fe2o3_text: Anneal code formatter -- Wadler layout algebra engine.

Lexer, parser, CST, Doc algebra, renderer, and format spec with Oxedyne style defaults. Multi-language lexer (Rust, Python, Go, Java, JS, C). 89 tests plus workspace regression (625 files, 97.8% idempotent). Fixes: OOM from empty-string starts_with guard, UTF-8 multi-byte char boundary handling, doc-comment and line-comment trivia preservation, if-let pattern disambiguation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

165 days agor1870400018:11643replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

22 operations, since the previous mark · compare with the head
+fe2o3_steel + fe2o3_syntax: admin_keys use base2x; fix help underflow.

Two small, related fixes discovered while building the companion oxegen keygen CLI. 1. fe2o3_steel: `admin_keys` `public_key` field now expects base2x HEMATITE64 (the canonical fe2o3 byte-string encoding used elsewhere in the keystore, e.g. for `wrapped_key`). Previously the parser accepted lowercase hex via a hand-rolled decoder, which drifted from the rest of the fe2o3 ecosystem and meant a keygen tool had to emit hex for no good reason. Keygen now emits base2x and Steel decodes base2x -- one encoding everywhere. The hand-rolled hex decoder in cfg.rs goes away. 2. fe2o3_syntax help rendering: `Help::to_lines` panicked with a subtract-with-overflow when an argument's label was longer than its description-column position. `try_range!(col - len, 0, ...)` did the subtraction before the range check, so the overflow fired before try_range had a chance to clamp. Replaced with a `saturating_sub` at each of the four affected sites in help.rs. A long argument name (like `oxegen keygen`'s `--output` help text) now renders with a single-space gap instead of panicking. No public API changes; tests pass unchanged. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

166 days agor1870400018:11620replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

17 operations, since the previous mark · compare with the head
+fe2o3_steel: signed-admin-login endpoints + head-injection config.

Step 3 (and the last fe2o3 piece) of the signed-credential admin-auth roadmap entry. A Steel instance can now accept an admin session without an interactive wallet passphrase: a remote operator holding one of the configured `admin_keys` public keys signs a #raw("SignedCommand") envelope with `cmd = "admin_login"` and posts it to `/admin/signed-login`; on a valid signature inside the freshness window, Steel issues the same session cookie the passphrase flow issues. The classical passphrase form at `/admin/login` stays available alongside -- operators choose per vhost which paths they configure keys for. Step 2 (a dedicated replay-window helper in fe2o3_shield) is folded in here as a private `NonceTracker` in `srv/admin/signed_login` rather than spun out, per the tight-taxonomy feedback: one caller today, promotable if a second shows up. ## VhostConfig Two new optional fields: - `admin_keys: Vec<AdminKey>` -- list of `{ name, scheme, public_key (hex), scopes }` entries. Empty list disables the feature for the vhost; existing deployments unaffected. - `head_injection_url: Option<String>` -- URL threaded through AdminState for rendering. Actually injecting it into every admin page's `<head>` requires touching every `render_layout` call site (~15 places across handler.rs); that plumbing is a deliberate follow-up. The config + state field are in place so the page-rendering change is localised when it lands. `AdminKey::from_dat` parses each entry, decoding `public_key` from lowercase hex and defaulting `scheme` to `"Ed25519"`. ## signed_login module `NonceTracker` is a small `HashMap<(Vec<u8>, [u8; 32]), u64>` with lazy eviction -- no background task, no extra thread. `record()` rejects a duplicate `(signer_id, nonce)` pair inside the freshness window. `handle_challenge` returns a JDAT body with `server_timestamp`, `freshness_secs` and `accept_cmd` so a client can align its envelope timestamp with the server's clock before signing. `verify_signed_login` parses the body as a `SignedCommand`, matches `signer_id` against the vhost's `admin_keys`, calls `verify_fresh` with a 120 s window, records the nonce and builds an `AdminPrincipal` matching the wallet-flow contract. Distinct outcomes (`MalformedBody`, `WrongCmd`, `UnknownSigner`, `BadSignature`, `ReplayedNonce`, `NoDashboardScope`) feed the audit log at the same log level as the passphrase failures, so an operator sees every rejection reason in the audit. Session duration mirrors the passphrase flow (one hour default). ## AdminState Gains three new fields -- `admin_keys: Arc<Vec<AdminKey>>`, `nonce_tracker: Arc<Mutex<NonceTracker>>`, `head_injection_url: Arc<Option<String>>` -- plumbed from the primary vhost's config at server start-up (`app/server.rs`). ## Routing Two new path constants in `admin/handler.rs`: `PATH_CHALLENGE = "/admin/challenge"` and `PATH_SIGNED_LOGIN = "/admin/signed-login"`. Added to the existing GET and POST match arms respectively. ## Tests Three new unit tests on `NonceTracker` (distinct/repeat, eviction after window, per-signer scoping). The pre-existing session.rs `mkstate()` test helper was missing fields added since it was written; updated in this commit so the Steel lib test suite (12 tests) is green again. `cargo build --workspace` clean. `cargo test -p oxedyne_fe2o3_o3db_sync --features dist` still 99/99 on the distributed-Ozone side. `cargo test -p oxedyne_fe2o3_crypto` still 29/29 on the keystore/credential/command primitives. ## Deferred - Actually injecting `head_injection_url` into the rendered pages. Requires threading through ~15 `render_layout` call sites; the state plumbing is in place. - Promoting `NonceTracker` to `fe2o3_shield` if a second consumer appears. - Rate-limiting the signed-login endpoint via `auth_guard`. The existing AddressGuard already bounds sensitive-path request rates globally; wiring the signed-login path into that list is a one-liner in `srv/https.rs` when desired. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

166 days agor1870400018:11602replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

34 operations, since the previous mark · compare with the head
+fe2o3_crypto: command module -- signed RPC envelopes.

Step 1c of the signed-credential admin-auth roadmap entry. Adds `SignedCommand { signer_id, scheme, cmd, args: Dat, timestamp, nonce: [u8; 32], sig }` and its sign/verify machinery. Bundles a command name, typed argument payload, unix timestamp and 32-byte nonce with a signature over the canonical byte encoding, so a receiver can authenticate an RPC-style message over any transport. Transport-agnostic by design: the envelope serialises via JDAT and rides whatever pipe the application uses. Freshness checking lives on the envelope (`verify_fresh`, `verify_fresh_at`); the replay-window tracker -- the stateful "reject re-used (signer_id, nonce) pairs" side -- is deliberately left out. It will land where it is actually consumed rather than in fe2o3_crypto. Canonical byte encoding mirrors credential's shape: leading version byte + length-prefixed fields (`[u8 v=1][u32 | scheme][u32 | signer_id][u32 | cmd][u32 | args_jdat_bytes][u64 timestamp][32 nonce]`). `args` is signed via its JDAT binary encoding so two peers agree on the signed bytes without coordinating a schema for arbitrary payloads. 12 unit tests cover sign/verify round-trip, tampering at every field (cmd, nonce, timestamp, args), wrong-signer rejection, the freshness window on both boundaries (inside and past), distinct nonces across successive signs (OsRng sanity), JDAT round-trip preserving the signature, from_dat nonce-length validation, and the version byte landing first in signed_bytes. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

166 days agor1870400018:11567replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

3 operations, since the previous mark · compare with the head
+fe2o3_crypto: credential module -- typed signed identity attestations.

Step 1b of the signed-credential admin-auth roadmap entry. A generic `SignedCredential { subject_id, subject_pk, issuer_id, scheme, valid_from, valid_to, sig }` carrying a signature over a canonical byte encoding of its non-signature fields. Agnostic to what "issuer" and "subject" mean semantically -- the caller decides whether they represent devices, peers, users, delegated agents, etc. A self-signed credential (issuer_id == subject_id) covers the bootstrap case where no prior authority exists to sign for you. Canonical byte encoding is length-prefixed with a leading version byte: `[u8 v=1][u32 scheme_len | scheme][u32 subj_id_len | subj_id] [u32 subj_pk_len | subj_pk][u32 iss_id_len | iss_id] [u64 valid_from][u64 valid_to]`. Length-prefixing lets arbitrary bytes sit in the id fields without delimiter ambiguity; the version byte turns future schema drift into a loud verify failure instead of a silent hash change. Signature scheme is named, not typed: the issuer's scheme name (one of `"Ed25519"`, `"Dilithium2"`, `"Dilithium2_fe2o3"`) is stored in the credential so a verifier reconstructs the right algorithm from the wire bytes plus the issuer's public key. Uses the existing SignatureScheme enum from fe2o3_crypto; no new primitive, no new dep. Validity window is inclusive on `valid_from` and exclusive on `valid_to`. Zero in either field means "no bound on that side". `Self::sign` rejects an empty window (valid_to <= valid_from with valid_to != 0) up front so a caller cannot publish a credential that would never verify. Public API: - `SignedCredential::self_sign(subject_id, scheme_with_sk, valid_from, valid_to)` -- bootstrap case. - `SignedCredential::sign(subject_id, subject_pk, issuer_id, issuer_scheme_with_sk, valid_from, valid_to)` -- third-party signing. - `verify(&self, issuer_pk)` / `verify_at(&self, issuer_pk, now)` -- signature + validity window check, rejects with distinct error tags for each failure mode. - `signed_bytes()` -- canonical encoding, public so callers can include a credential in a larger signed payload without duplicating the layout logic. - `is_self_signed()` -- convenience. - `to_dat` / `from_dat` -- JDAT round-trip. 9 unit tests cover self-sign verify, third-party sign verify, tampering detection (flipped subject_pk byte), wrong-issuer-pk rejection, validity-window both boundaries (not-yet-valid and expired), empty-window rejection at sign time, JDAT round-trip preserving the signature, and the version byte actually landing first in signed_bytes. Also fixes a pre-existing linker issue in `fe2o3_crypto`'s test binary: `libcrypto.a` pulls in zlib and zstd symbols via its c_zlib.o / c_zstd.o wrappers which were not being resolved for `cargo test`. Adding `cargo:rustc-link-lib=z` and `=zstd` to build.rs unblocks the test binary without changing the library build (downstream consumers pick those up transitively). The fix also unblocks the four keystore unit tests moved in commit e4bce5f. Full fe2o3_crypto test run now 29 passing. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

166 days agor1870400018:11563replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+fe2o3_crypto: split wallet code out of keys into a new keystore module.

Step 1 of the signed-credential admin-auth roadmap entry. Lifts the multi-admin encrypted keystore types out of keys.rs, where they had been sharing a file with the raw key types (SecretKey / PublicKey / Keys<>), and into a dedicated keystore module. Move-only: no type renames, no logic changes. keys.rs shrinks from 1037 to 295 lines; keystore.rs lands at 612 lines (including the 4 existing unit tests moved over intact). Motivation: keys.rs was two distinct concerns in one file. The raw key types are generic crypto plumbing; the wallet / AdminUser / UnlockedWallet / WrappedKey / wrap_master_key / unwrap_master_key family is a higher-level encrypted-multi-admin-keystore primitive. Giving the second family its own module makes the scope explicit and gives a clean place for upcoming extensions (additional unlock backends -- OS keyring, raw, and the signed-command path being added elsewhere) without growing keys.rs further. The taxonomy stays tight -- no new crate, just a new sibling module. Public-path change: `oxedyne_fe2o3_crypto::keys::{Wallet, AdminUser, UnlockedWallet, WrappedKey, wrap_master_key, unwrap_master_key, DEFAULT_WALLET_KDF_NAME, WALLET_MASTER_KEY_LEN}` moves to `oxedyne_fe2o3_crypto::keystore::{...}`. SecretKey, PublicKey and Keys<> stay in `keys`. Downstream imports updated in fe2o3_steel (app/repl.rs, app/tui.rs, srv/admin/state.rs, srv/admin/session.rs, srv/admin/handler.rs) and fe2o3_shield (app/repl.rs, app/tui.rs). Build: cargo build --workspace is clean. Tests: fe2o3_o3db_sync --features dist still passes all 99 dist-mode tests (which exercise the keystore path transitively via the O3db storage adapter's encryption setup). Two pre-existing test-binary issues remain -- unrelated to this split: the fe2o3_crypto test binary fails to link against zlib symbols pulled in transitively by the OpenSSL-linked C code in that crate, and fe2o3_steel's session tests have a stale AdminState initializer missing the addr_guard / auth_guard fields that were added to the struct since the test was written. Both existed before this commit. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

166 days agor1870400018:11557replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: O3db-backed Storage adapter for distributed Ozone.

Plumbs the distributed-Ozone `Storage` trait onto a local `O3db` instance so distributed mode can be driven by the real on-disk engine instead of the in-memory `MemoryStorage` used by the existing tests. The adapter is the storage half of step 9 on the distributed-Ozone roadmap; the Shield transport adapter and the `O3db::new_distributed` wrapper that ties them together remain follow-up. Encoding: - Key: `Dat::Str("{table}:{hex_id}")`. The `"{table}:"` prefix lets a `scan` with `ScanOpts::with_str_prefix` enumerate every record in a single table, which anti-entropy's `digests` round needs. `hex_id` is a 64-character lowercase-hex encoding of the 32-byte `RecordId`. - Value: `Dat::BU8(record.value)`. The record's table and id are recoverable from the key, so the stored payload carries only the application-opaque bytes. Operations: - `put` stores via `api().store` and drains the two-message `Chunks`/`KeyExists` ack, surfacing any `Error` message as an `Outcome` error. - `get` fetches via `api().get_wait`. A `Dat::Usr` tombstone -- the `usr_kind_id_deleted()` marker -- is mapped to `None` so deletion shows through immediately on the next read. - `delete` overwrites the key with a tombstone `Dat::Usr` via the ordinary `store` path rather than `delete_using_responder`. Going through `store` means the tombstone flows through the same encryption and cache pipeline as a regular write, so subsequent `get` calls see it on the first read instead of racing the deletion path's unencrypted direct-to-disk write. The existence check for the `Storage::delete` return value is done by a pre-fetch since the write-ack shape does not surface a reliable "was-previously-present" flag. - `digests` scans the table by prefix and does a per-record fetch to compute the 32-byte content hash. O(n) fetches per anti-entropy round; acceptable for small cohort-backed tables and optimisable when scan grows a "with-values" mode. The content hash is splitmix64-widened to match the one `MemoryStorage` uses so a mixed cluster (in-memory peer + O3db peer) reconciles without digest disagreement; it is not cryptographic. Integration test boots a fresh `O3db` in `./test_db_dist_o3db_storage`, exercises put/get/delete/digests across two tables and three records, verifies content-hash divergence for different payloads, and confirms graceful shutdown via `Arc::try_unwrap`. Four in-module unit tests cover hex codec, key round-trip and content-hash determinism. sec_ozone.typ step 9 updated to reflect that the O3db-backed `Storage` adapter has landed; the Shield transport adapter and the `O3db::new_distributed` wrapper remain follow-up. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11533replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: cohort write-path dispatch + fold dist layer into o3db_sync.

Two changes in one commit -- the second one moves the files produced by the first, so separating them would leave an intermediate state with dangling paths. ## Cohort write-path dispatch (step 7b of the distributed Ozone roadmap) DistOzone::put previously rejected Consistency::Cohort tables with Unimplemented. This commit wires HotStuff in so writes to a cohort-backed table drive through a full Prepare -> PreCommit -> Commit -> Decide cycle, matching the happy path of fe2o3_hotstuff. New module dist::consensus: - CohortInstance bundles a HotStuff Replica with the deterministic cohort-member list and the replica-id <-> node-id mapping. Created lazily on the first cohort message for a given (table, record_id), retained after Decide so duplicate late messages are absorbed without re-firing. - encode_record / decode_record serialise a Record to the opaque block payload HotStuff carries and back. - block_hash is a deterministic 32-byte splitmix64-widened hash, used to identify a block across peers. Not crypto-strong; callers wanting adversary-resistance can swap to SHA3 without touching the protocol (BlockHash is already 32 bytes). New transport variants: - CohortSubmit { record } -- forwarded write from a peer that is not the initial leader. - CohortPropose { table, id, proposal } -- leader-to-cohort proposal. - CohortVote { table, id, vote } -- replica-to-leader vote. - CohortNewView { table, id, new_view } -- view-change payload. Engine changes (dist::engine::DistOzone): - put branches on Consistency. Eventual keeps its original replicate-broadcast path. Cohort selects the cohort, and either opens a HotStuff round (local is initial leader) or emits a single CohortSubmit envelope to the leader. - handle_envelope gains four arms matching the new variants. They lazily create a CohortInstance, feed the message into the replica, and translate the resulting Commands into envelopes. - translate_commands converts Command::BroadcastProposal, Command::SendVote, Command::SendNewView and Command::Decide into wire envelopes. Self-targeted sends are folded back into the local replica so a leader that is also a voter aggregates its own vote consistently. Command::Decide decodes the block, marks the instance decided and persists the record to local storage. - cohort_timeout(table, id) exposes HotStuff's on_timeout to applications that want to drive view change from their own timers. - PutOutcome gains consensus_pending: Option<(String, RecordId)>. InboundOutcome gains completed_consensus_put. - fe2o3_hotstuff Qc / Proposal / Vote / NewView gain Eq / PartialEq so they can be embedded in MsgKind and keep MsgKind's derives. 11 new integration tests in dist_consensus.rs cover 5-peer happy path, submit-forwarding from a non-leader, routing through the leader from a non-member, idempotent replay, parallel independent records, silent- leader timeout producing a NewView to the next view's leader, and three drop-silently paths (submit to non-leader, vote without instance, propose to non-member). ## Packaging: fold fe2o3_o3db_dist and fe2o3_hotstuff into o3db_sync The fe2o3_o3db_sync "sync" suffix refers to the concurrency model (OS threads rather than async tokio), not to a consistency mode. Having fe2o3_o3db_dist as a sibling crate therefore read as "sync vs dist" when both use threads -- the real distinction is "local vs distributed", and distributed is better understood as a mode of o3db_sync. - fe2o3_o3db_dist/src/* -> fe2o3_o3db_sync/src/dist/* (dist.rs renamed to engine.rs to avoid dist::dist::DistOzone). - fe2o3_hotstuff/src/* -> fe2o3_o3db_sync/src/dist/hotstuff/* (HotStuff has exactly one consumer; no reuse benefit to keeping it as a standalone crate right now). - Test files moved to o3db_sync/tests/dist_*.rs, each gated by #![cfg(feature = "dist")]. - New cargo feature 'dist' on o3db_sync pulls the distributed deps (hll, iblt, kademlia, oam) as optional -- local-only callers pay no compile cost for the peer-to-peer primitive graph. - fe2o3_hotstuff and fe2o3_o3db_dist crates deleted. Builds: cargo build --workspace is clean; cargo build -p oxedyne_fe2o3_o3db_sync (no features) is clean; --features dist is clean. Tests with --features dist: 14 anti-entropy, 12 cohort, 11 consensus, 31 engine, 18 hotstuff = 86 distributed-mode tests, all passing. Without the feature, dist tests compile to empty. Hematite doc and memory notes updated: the primitive-crate recipe now acknowledges HotStuff as a folded-in exception, and the distributed-Ozone project memory finalises the packaging decision. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11527replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

93 operations, since the previous mark · compare with the head
+fe2o3_o3db_dist: cohort selection primitive for HotStuff-backed tables.

Foundation for step 7b of the distributed Ozone roadmap. Answers "who sits in the HotStuff cohort for this (table, record)?" purely from the peer set and the table / record identifiers, with no messages exchanged. The HotStuff dispatch and rounds themselves remain follow-up; this commit lands only the selection layer so the rest can plug in without rediscovering membership. - cohort::select(table_name, record_id, peer_set, local_id, lambda) returns a Cohort { members, leader, local_is_member, local_is_leader }. Membership is the lambda peers closest in XOR distance to the seed H(table_name) XOR record_id, where H(table_name) is a splitmix64-mixed 256-bit derivation of the table name (same style as TableConfig::iblt_seed, broadened to 32 bytes). Ties break on NodeId byte order so two peers never disagree on a cohort. - Local peer is always a candidate -- the selection does not pre-exclude self, so the cohort can include the writer without a separate re-check. - lambda = 0 is handled explicitly as an empty cohort; the common lambda values 5, 7, 9 are validated at TableConfig::new and pass through as-is. lambda > peers.len() + 1 clamps to the available candidate count. - Leader is the closest member to the seed (members[0]); HotStuff rotates from there using its own round counter. 12 integration tests covering: lambda = 0 degenerate case, size matches lambda when enough peers are available, clamping to the candidate count when not, determinism across repeated calls, distinct cohorts for different records and different tables, leader == members[0], local_is_member / local_is_leader consistency, no duplicate members, broad distribution across 40-peer / 400-trial / lambda = 5, equal-lambda-and-candidates includes-everyone case, and structural equality of the Cohort struct. sec_ozone.typ step 7 updated: cohort selection delivered; HotStuff-driven write-path dispatch (proposal submission, round driving, Decide application) remains follow-up. Total crate tests now 57 (31 dist + 14 anti-entropy + 12 cohort). Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11433replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+fe2o3_o3db_dist: IBLT anti-entropy reconciliation cycle.

Step 6 of the distributed Ozone roadmap: per-table symmetric-difference reconciliation over IBLT sketches, with bulk-transfer fallback on decode overload. Plugs into the DistOzone state machine as three new message variants and four new methods; no new dependencies beyond fe2o3_iblt, which was already in the crate's import graph. - TableConfig gains iblt_cells: usize (default 256) plus DEFAULT_IBLT_CELLS and IBLT_NUM_HASHES (= 3) constants. iblt_seed() is derived deterministically from the table name so different tables use different hash functions without operator configuration. TableConfig::new now rejects iblt_cells == 0; the convenience constructors eventual() and cohort_default() pick up the default. - MsgKind gains AntiEntropyDigest { table, sketch }, AntiEntropyReply { table, records, requested_ids, bulk } and AntiEntropyPush { table, records }. The reply's `bulk` flag signals the sketch overloaded and the recipient fell back to dumping every record it holds for the table; the originator absorbs what it lacks and skips the push follow-up. - DistOzone::build_anti_entropy_request(table, target_peer) builds a digest envelope the caller can dispatch. Rejects unknown tables and cohort-backed tables (those reconcile through consensus). - handle_envelope dispatches to three new private handlers: AntiEntropyDigest rebuilds the local sketch, subtracts, decodes, and emits an AntiEntropyReply. Rejects mismatched sketch shapes (config mismatch) so a misconfigured or malicious sender cannot make the peeling decoder produce nonsense. AntiEntropyReply persists every record (re-checking placement) and emits an AntiEntropyPush for every requested id the recipient actually holds. AntiEntropyPush persists every record (re-checking placement). - Placement re-check on every persistence path means a stale-N sender cannot force records onto peers that have since stopped considering themselves holders -- identical invariant to ReplicatePut. 14 new integration tests in tests/anti_entropy.rs covering: construction rejection (unknown / cohort tables), empty reconciliation when peers are in sync, both diff directions (sender-extra and receiver-extra), reply-triggered persistence, reply-triggered push follow-up, push persistence, overload -> bulk fallback, end-to-end two-peer convergence across {1,2,3} vs {2,3,4} reaching {1..=4} on both sides after one round, cohort-table rejection on receive, unknown-table rejection on receive, config-mismatch rejection, and placement re-check dropping records when the receiver is not a holder. Total crate tests now 45 (31 existing + 14 anti-entropy), all green. sec_ozone.typ step 6 marked delivered. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11427replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

28 operations, since the previous mark · compare with the head
+fe2o3_o3db_dist: new crate -- distributed Ozone engine scaffolding.

Step 9 (Application-facing API) of the distributed Ozone roadmap in skeleton form. A pure state-machine engine that composes every primitive delivered to date (Kademlia, OAM, HLL, IBLT -- HotStuff when cohort tables come online) behind a small application surface. Transport stays outside the crate, matching the fe2o3_hotstuff / fe2o3_kademlia pattern: every method either reads state or returns envelopes for the caller to dispatch. Public surface: - DistOzoneConfig { local_peer_id, bootstrap_peers, oam, tables } with duplicate-table-name and empty-table-list validation. - TableConfig / Consistency (Eventual | Cohort { lambda }) with lambda ∈ {5, 7, 9} enforced by constructor. - PeerSet -- sorted, deduplicated, self-excluding rolling view with O(log n) binary search and O(n) insert / remove. - Placement -- caches the OAM threshold, exposes i_am_holder, remote_holders, read_targets, decide. - Storage trait -- put / get / delete / digests. MemoryStorage as an in-tree HashMap-backed adapter for tests and two-peer demos; the production adapter over fe2o3_o3db_sync is a follow-up step. - Envelope + MsgKind (ReplicatePut, GetRequest, GetResponse). Signing and encryption are the transport adapter's responsibility. - DistOzone<S: Storage>::{ new, put, get, handle_envelope, poll_get, cancel_get, set_read_fanout, insert_peer, remove_peer, update_network_size }. Behavioural properties: - put returns outbound envelopes for every remote holder; local persistence is conditional on placement. Unknown tables error; cohort-backed tables error pending the consensus path. - get short-circuits to local storage when the local peer is a holder; otherwise fan-outs a GetRequest to read_fanout nearest peers (default 3), returns a request id, and resolves via poll_get. - handle_envelope re-checks placement on ReplicatePut so a sender's stale view of N cannot force a record into a peer that no longer considers itself a holder. GetResponse correlates by request id and marks the pending read Resolved when the first non-empty response lands or every target replies empty. - update_network_size refreshes the cached threshold, giving the HyperLogLog-driven N-estimate refresh a one-liner mutation point. 31 integration tests against MemoryStorage + two-engine loopback covering config validation, peer-set invariants, placement branching, write-path envelope construction, local / remote / NoTargets get branches, inbound ReplicatePut placement re-check, GetResponse correlation (record arrival, all-empty resolution, unknown request id), cancel, misaddressed rejection, peer-set mutation and update_network_size refresh. sec_ozone.typ updated: step 5 marks the placement primitive and the write / read dispatch delivered with anti-entropy as its own future step, step 9 marks the API scaffolded with the fe2o3_o3db_sync wrapper as a follow-up, and a new paragraph under §Packaging documents the crate surface. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11398replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+fe2o3_oam: new crate -- Oxegen Allocation Mechanism placement primitive.

Pure algorithm layer for step 5 of the distributed Ozone roadmap (sec_ozone.typ §Data Allocation: OAM). No I/O, no async, no transport. - OamConfig { replication, network_size } with default replication = 20 and an explicit guard against the "replication > 0, network_size = 0" operator mistake. default_replication() constructs the common case. - Threshold computes floor(2^256 * n / N) via exact 256-bit long division over five big-endian u64 limbs (no floating-point, no third-party bignum dependency). Two peers with the same (n, N) produce byte-identical thresholds regardless of platform. - Saturation edges are explicit enum variants: Threshold::None (n = 0), Threshold::Bounded(bytes) (0 < n < N) and Threshold::All (n >= N or N = 0). The primitive never silently clamps a bad input to a middle case. - placement::is_holder reduces to one XOR-distance computation plus a bytewise slice comparison; placement::holders filters a peer set; placement::closest_holders returns the c peers nearest the record hash regardless of threshold, for reads that must route to a peer who considers itself a holder. - Reuses oxedyne_fe2o3_kademlia::id::{NodeId, Distance, ID_LEN} so the Kademlia routing space and the OAM placement space are the same 256-bit identifier space. No duplication. - 21 integration tests: degenerate thresholds (None / All), exact fractional thresholds (1/2 -> top bit, 1/4 -> second bit), config validation, zero-distance holder behaviour, determinism, operand symmetry, uniform-sampling convergence at n=20 N=500 over 10 000 trials (+/- 15% window), holders / closest_holders correctness and ordering, roundtrip through as_bytes / as_node_id, and monotonicity in the replication factor. 1 doctest. sec_ozone.typ updated with the §Data Allocation primitive surface paragraph and the roadmap step marked delivered. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11376replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

13 operations, since the previous mark · compare with the head
+fe2o3_hotstuff: view change, leader rotation and safeBlock safety rule.

Upgrades the happy-path skeleton (ce5f419) into Basic HotStuff with recovery from a silent or misbehaving leader. Still a pure determin- istic state machine; no transport, no crypto, no time. Additions: - NewView message type carrying each replica's highest prepare_qc to the incoming leader. - Round-robin leader rotation: leader_for(view) = (view - 1) mod cohort_size. Config drops the explicit leader_id; Replica derives the current leader from view_id. - Per-replica prepare_qc and locked_qc state, updated as each phase's QC reaches the replica (via on_proposal of the next phase, or via the leader's own on_vote when it forms the QC). - on_timeout: advances the view, clears per-view state, emits SendNewView to the new leader. - on_new_view: leader collects NewView quorum. If any replica held a prepare_qc, the leader re-proposes the pinned block; if none did, the state machine exposes `awaiting_fresh_block` and the caller supplies a fresh block via propose(). - safeBlock predicate: in view > 1, Prepare proposals must either re-endorse the locked block or carry a justify from a newer view; a justify-less Prepare is legal only when no lock is held. Tests (4 new, 18 total, all passing): - silent_leader_recovers_via_view_change: leader 0 stays silent, every replica times out, leader 1 takes over with a fresh block, cohort decides. - view_change_preserves_pinned_block_when_prepare_qc_exists: leader 0 reaches PreCommit, cohort times out before Commit, leader 1 sees prepare_qc in NewViews and re-proposes the same block. - view_change_rejects_unsafe_proposal: a replica locked on block B in view 1 rejects a Byzantine leader's view-2 Prepare for a different block with a view-1 justify (safety). - view_change_accepts_later_view_proposal: a non-locked replica accepts a different block in view 2 (liveness). sec_ozone.typ updated to describe the full primitive and the roadmap step now marks only checkpointing and Byzantine-fault simulation as follow-up work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11362replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

129 operations, since the previous mark · compare with the head
+fe2o3_hotstuff: new crate -- happy-path three-phase HotStuff skeleton.

Step 7 of the distributed Ozone roadmap (sec_ozone.typ §Consensus Cohorts). Pure deterministic state machine with no transport, no crypto, no notion of time. Scope delivered: - Replica state machine driving a single view through Prepare -> PreCommit -> Commit -> Decide under a fixed leader. - Proposal / Vote / Qc types; signatures ride through as opaque bytes so the wire format is self-contained. Real deployments verify before handing votes to on_vote. - Config supporting the spec's three cohort sizes (lambda = 5/7/9, z = 1/2/2, quorum = lambda - z). - Command enum (BroadcastProposal / SendVote / Decide) drives the caller's networking layer without the primitive knowing about it. - Qc::validate enforces view, phase, block-hash, quorum and ascending- unique voter ids. - 13 integration tests: degenerate-config rejection, quorum math for all three spec lambdas, rejection of propose on non-leader, three cohort sizes reaching Decide unanimously through an in-memory driver, duplicate-vote idempotence, out-of-range voter rejection, structural rejection of malformed proposals, and three QC-validation edge cases. Deliberately deferred (flagged in the crate doc and the roadmap note): view change, leader rotation, locked/prepared safety rules, checkpointing, Byzantine-fault simulation tests. These land in a follow-up so OAM (step 5) and the anti-entropy loop (step 6) can proceed against a working happy-path consensus skeleton in parallel. sec_ozone.typ updated with the primitive surface paragraph and the roadmap step marked partially delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11232replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+fe2o3_iblt: new crate -- Invertible Bloom Lookup Table primitive.

Pure algorithm layer for step 4 of the distributed Ozone roadmap (sec_ozone.typ §State Reconciliation). No I/O. - Iblt over fixed-length byte keys and values; value_len = 0 is the common key-only anti-entropy mode. - insert / delete apply +/-1 contributions across num_hashes cells chosen by double hashing. - subtract reduces two IBLTs of matching IbltConfig into their symmetric-difference representation (cellwise XOR on sums, cellwise subtract on signed counts). - decode runs the peeling loop, returning DecodeOutcome::Complete { inserted, deleted } on a fully-drained table and DecodeOutcome::Incomplete { remaining_cells, .. } on overload, with partial results preserved. - Internal hash is a seeded splitmix64 mixer over 8-byte chunks; adversarial callers pre-hash their keys cryptographically. - to_bytes / from_bytes cover the wire format (5 × u64 header + per- cell key_xor || value_xor || fp_le || count_le). - 14 integration tests: rejection of degenerate configs, insert / delete identity, self-subtract, symmetric-difference recovery at k=3, overload -> Incomplete, value-carrying recovery, roundtrip through to_bytes / from_bytes, config mismatch on subtract, and order-independence of insert sequences. 1 doctest. sec_ozone.typ updated with the primitive surface paragraph and the roadmap step marked delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11220replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

11 operations, since the previous mark · compare with the head
+fe2o3_hll: new crate -- HyperLogLog cardinality-sketch primitive.

Pure algorithm layer for step 3 of the distributed Ozone roadmap (sec_ozone.typ §Network Size Estimation). No transport, no async, no bundled hash function. - HyperLogLog sketch with 2^p single-byte registers, p in [4, 18], P_DEFAULT = 14 giving a 16 KiB sketch matching the spec. - add_hash(u64) takes a caller-supplied 64-bit hash -- the caller picks SeaHash / SipHash / truncated cryptographic hash per domain. - merge by register-wise maximum; mismatched precision is an error. - estimate combines raw HyperLogLog with linear-counting small-range correction. Expected standard error at P_DEFAULT is ~0.8%, inside the 2% spec target at 10^6 peers. - from_bytes / as_bytes for on-wire sketch exchange during anti-entropy. - 15 integration tests covering precision bounds, register layout, merge union / idempotence / precision mismatch, duplicate-insert invariance, accuracy at 10^4 and 10^5 cardinality, and register theoretical-bound sanity. 1 doctest. sec_ozone.typ updated with the primitive surface paragraph and the roadmap step marked delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11208replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+fe2o3_kademlia: new crate -- Kademlia DHT routing-table primitive.

Pure data-structure + algorithm layer for step 2 of the distributed Ozone roadmap (sec_ozone.typ). No transport, no asynchrony, no I/O. - NodeId (256-bit) with XOR Distance and bit-exact bucket indexing. - Contact + opaque Capabilities bitfield, no pk tracking (keys belong one layer up in fe2o3_crypto and the distributed Ozone integration). - KMap with LRU-biased replacement surfaced through InsertOutcome::Full { candidate, pending }: the bucket hands its LRU back to the caller for an external liveness probe; on a live reply the caller invokes keep_lru, on a dead reply evict_and_insert. - RoutingTable holding 256 KMaps, k_closest serving both FIND_NODE and FIND_CLOSEST at the message layer. - 13 integration tests covering XOR, bucket indexing, LRU policy, full bucket overflow, self-rejection, k_closest ordering and cap, and a two-peer mutual-discovery sanity check. 1 doctest. sec_ozone.typ updated with a primitive-surface paragraph in the Kademlia section and the roadmap step marked delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11198replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

15 operations, since the previous mark · compare with the head
+fe2o3_tui: fix ironic bin after LoggerConsole trait drift.

AppLoggerConsole held a stale LoggerConsole impl: Msg::Console had grown a stream-key field, listen took &mut self, and the trait now requires a no-arg new() that does not fit a struct needing an external app_log handle. The trait is never used polymorphically here -- the struct is constructed directly in main.rs and its go/listen methods called on the concrete type -- so drop the trait impl, merge go and listen into the existing inherent impl, and factor the duplicated write-or-report block into an append_to_log helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11182replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

9 operations, since the previous mark · compare with the head
+fe2o3: bump bytes and rustls-webpki to clear transitive advisories.

bytes 1.9.0 -> 1.11.1 (RUSTSEC-2026-0007: integer overflow in BytesMut::reserve) rustls-webpki 0.103.11 -> 0.103.12 (RUSTSEC-2026-0098: URI name constraints incorrectly accepted, RUSTSEC-2026-0099: name constraints accepted for wildcards) Both are transitive via tokio / tokio-rustls and resolve cleanly under the existing semver bounds. cargo build --workspace --lib is clean. Remaining cargo audit findings are unmaintained-crate notices or vulnerabilities not reachable on any fe2o3 attack surface (ring AES panic only with debug overflow checks, crossbeam double-free not externally triggerable, time DoS only with attacker-controlled parse input on a path fe2o3 does not use). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11172replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

5 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: untrack test_db artefacts and gitignore them.

The test_db/ and test_db_zone_container/ directories under fe2o3_o3db_sync/ are regenerated by every test run; tracking them produced a permanently-dirty working tree. Removed from the index (left on disk for any in-flight test run) and added a gitignore rule that catches these and any future test_db_*/ directories under the crate. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

167 days agor1870400018:11166replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

18 operations, since the previous mark · compare with the head
+fe2o3_steel: mark admin_local_port #[optional] for backwards compat.

A karri redeploy with the pivoted binary failed because the production config.jdat on that host predates the admin_local_port field added in the localhost-admin-listener commit. The FromDatMap derive refused the load with 'required struct field admin_local_port cannot be found', silently returned Evaluation::Error, and the process exited without binding any ports -- public site down. Marking the field #[optional] lets pre-admin_local_port configs load cleanly (missing field falls through to the Default impl's zero, which reproduces the disabled-by-default behaviour). Operators who want the localhost admin listener still add the field to their config; operators who don't need it get their old config to keep working. Retrospectively this should have been #[optional] from the start -- every field added to a deployed config struct is a potential forced-edit for every running instance. The hardening fields added earlier in this session were marked #[optional] precisely for this reason; admin_local_port was the one older addition I missed.

167 days agor1870400018:11147replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

1 operation, since the previous mark · compare with the head
+fe2o3: backwards-compat for new config fields + proxy header propagation.

Three follow-ups shaken out while smoke-testing the Steel architecture pivot against elearnity's 22-probe integration test. 1. OzoneConfig: every durability-barrier field (sync_on_write, sync_every_n_writes, sync_interval_ms) is now marked #[optional] so an on-disk config.jdat written by a pre-feature Steel binary still loads. Missing fields fall through to the Default impl, which reproduces the pre-feature behaviour (no fsync). Without this, existing deployments would refuse to start after upgrade because the FromDatMap derive requires every field by default. 2. ServerConfig: same treatment for the eight hardening fields (http_max_header_bytes, http_max_body_bytes, http_header_read_timeout_ms, security_headers_enabled, content_security_policy, addr_guard, auth_path_prefixes, auth_rps_max). Missing fields recover the pre-feature defaults. Existing configs keep loading; new deployments that want hardening fill in the block. 3. ApiRoute proxy: forward_api_proxy now propagates a curated set of incoming client headers (Accept, Accept-Language, Accept-Encoding, Content-Type, User-Agent, Authorization, Origin, Referer) to the upstream in addition to the route-configured static headers. This was a regression from the pivot: in-process API handlers had direct access to the request header fields, but the proxy path only carried the static route headers plus Content-Type. Handlers that depend on client headers -- elearnity's /api/geo reads Accept-Language to seed the country dropdown -- stopped working through the loopback proxy. Route-configured headers still win over client headers with the same name so operator-declared values (Authorization: Bearer <secret>) cannot be overridden client-side. Verified end-to-end against elearnity's 22-probe integration test (4 HTTP + 8 API + 10 WebSocket). All 22 pass with the stack split into a generic Steel binary and a loopback elearnity_app binary; the previous run with the pivot regression on /api/geo is now resolved.

168 days agor1870400018:11145replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

27 operations, since the previous mark · compare with the head
+fe2o3_net + fe2o3_steel: plain-HTTP upstreams and webhook forwarding.

Preparation for the Steel architecture pivot: per-app business logic needs to run as a standalone process behind Steel, with Steel acting purely as the control and data plane. The pivot requires three capabilities the proxy path did not have before: 1. Plain-HTTP upstreams. fe2o3_net now exposes http_request as a sibling of https_request, and ApiRoute::parse_upstream accepts both http:// and https:// URL forms (defaulting the port to 80 or 443 respectively). Plain HTTP is only meaningful for loopback; third-party API proxying keeps TLS. 2. GET as well as POST in the proxy path. ApiRoute proxy routes now match on GET too, so an app binary can expose dynamic JSON endpoints Steel fronts without having to register a dedicated in-process handler. handle_get gains a proxy branch that matches and forwards; the POST branch is factored into the same shared forward_api_proxy helper. 3. Webhook upstream mode. WebhookRoute.handler becomes Option<String> and gains a mutually exclusive upstream_* quintet. handle_post detects upstream-mode webhook routes and forwards the raw body and a curated set of headers (Content-Type, User-Agent, anything matching signature/idempotency heuristics) so downstream Stripe / GitHub style signature verification still sees an unmodified payload on the app side. Existing call sites updated: from_datmap parse paths thread the new tls flag and optional handler through, webhook::dispatch bails out on upstream-mode routes (they are handled directly in https.rs before it is called), the proxy branch picks http_request or https_request based on the route's upstream_tls flag. No config-file changes are required for existing deployments: an upstream URL written as https://... still parses identically to before, handler-mode webhook routes still work, and the proxy path still returns the same upstream response shape.

168 days agor1870400018:11117replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

44 operations, since the previous mark · compare with the head
+fe2o3_steel: outbound egress allow-list per vhost.

Adds VhostConfig.egress_allowed: a list of allowed host or host:port strings. When non-empty, every api_routes upstream must match at least one entry; a vhost whose proxy route targets an upstream outside the list is refused at server start-up (ServerConfig::validate now calls VhostConfig::validate_egress). Empty list (the default) disables the check, so deployments that have not yet adopted the allow-list continue to work exactly as before. Turning the list on is a one-line config change that converts an exfiltration bug -- a compromised config swapping a trusted upstream for attacker-controlled.example.com -- from a silent success into a hard startup failure.

168 days agor1870400018:11072replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

6 operations, since the previous mark · compare with the head
+fe2o3_steel: addr guard config block + per-route auth rate limit.

Two related hardening knobs that both rely on the generic fe2o3_net::guard::addr::AddressGuard shipped earlier. AddrGuardSettings lifts the guard's five runtime thresholds (rps_max, tint_min, tsunset_base, tsunset_spread, blist_cnt) out of the hard-coded SteelAddressGuard defaults and into a runtime structure. ServerConfig grows an addr_guard map block that a deployment can use to tighten the numbers without recompiling, with every field optional: a missing field (or an empty block) falls back to the compile-time default. Parsed via ServerConfig::get_addr_guard_settings(), applied via admin::guard::new_shared_with(). Per-route rate limit: a second SteelAddressGuard instance, the auth_guard, runs against sensitive URL prefixes (default /login, /admin/login) with a tighter rps cap (default 5 rps vs the general 50). ServerConfig.auth_path_prefixes and auth_rps_max control this. The HTTPS dispatcher consults auth_guard after the request line has been parsed; a block returns 429 Too Many Requests and closes the connection without reaching the handler or the general addr_guard's state, so a brute-force password hammer gets kicked off the login surface faster than a normal browsing session without affecting the same client's ability to request non-auth resources. Both guards are held on AdminState so the HTTPS handler and the dashboard share one instance each. The Security view still reads and mutates the general addr_guard; exposing the auth_guard in the dashboard is a later commit.

168 days agor1870400018:11065replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

21 operations, since the previous mark · compare with the head
+fe2o3_net + fe2o3_steel: HTTP read limits and security response headers.

Closes three cheap exposure vectors on the HTTPS accept path: 1. Oversized request bodies now return 413 Content Too Large before a single byte is read. HttpMessage::read checks the declared Content-Length against the caller's ReadLimits and rejects with IO+Network+Input+TooBig; the body read loop enforces the same ceiling on the wire as defence in depth. 2. Oversized request headers return 413 as soon as the accumulator exceeds the configured byte budget. Protects against cookie-stuffing and other cheap memory exhaustion attacks. 3. Slow-header (slowloris) trickle attacks now hit a wall-clock budget on the HTTP header read phase: HttpHeader::read wraps every stream.read in tokio::time::timeout tied to the caller's configured deadline, and disconnects with 408 Request Timeout when the budget is exceeded. Wired through ServerConfig::http_max_header_bytes / http_max_body_bytes / http_header_read_timeout_ms, each defaulting to 16 KiB / 8 MiB / 15 s. A zero value disables the corresponding check so deployments that need it can flip a single field to opt out. HttpMessageReader grows a `with_limits` constructor; the old `new` still exists for outbound HTTP clients where the peer is trusted (shield, ws core, test client, http client). fe2o3_steel's HTTPS dispatcher catches the new error tags (TooBig, Timeout) and writes a proper HTTP response before closing the connection, so the client sees a deliberate rejection instead of a silent drop. Second half of the commit: every HTTPS response now carries a baseline set of security headers when ServerConfig .security_headers_enabled is true (the default): X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: sensor features denied by default Plus an optional Content-Security-Policy driven by the new ServerConfig.content_security_policy string -- empty by default because a strict CSP would break existing front ends, so operators opt in per deployment. Adds a ReferrerPolicy variant to HeaderName since the previous enum did not cover it.

168 days agor1870400018:11043replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

40 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: durability barrier via configurable fsync policy.

Gap #1 from the ozone professional-grade inventory: writes ack as soon as the kernel page cache accepts the bytes, so a power loss or panic can lose acknowledged data. This commit adds the first tier of the durability story -- an explicit, configurable fsync step under operator control. OzoneConfig gains three coupled fields: sync_on_write : bool -- fsync after every write sync_every_n_writes: u32 -- group commit on write count sync_interval_ms : u64 -- group commit on elapsed wall clock Evaluated in priority order inside WriterBot::maybe_sync_files, which runs at the end of each successful (key, value) pair so data, index and acknowledgement all land on the same side of the barrier. Uses sync_data (fdatasync on Linux) because ozone recovery does not rely on filesystem metadata, only on byte contents -- measurable throughput win versus sync_all on rotational media with no durability trade-off. WriterBot state grows a (writes_since_sync, last_sync_at) pair so the group-commit policies make a local decision without touching shared state, and the hot loop stays free of global counters. Defaults across every existing OzoneConfig construction (cfg.rs, test/setup.rs, fe2o3_steel::srv::context, fe2o3_shield::srv::context) keep durability OFF, matching pre-feature behaviour so existing test suites and production deployments get bit-for-bit the same throughput. Operators opt in explicitly by flipping one of the three knobs. New tests/durability.rs exercises all three policy branches end to end: writes a handful of keys under each policy, reads them back, asserts round-trip. Runs in ~2 s on top of the existing basic/dal/scan integration suite.

168 days agor1870400018:11002replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

20 operations, since the previous mark · compare with the head
+fe2o3_steel: persist Overview sparkline history to ozone.

Dashboard history was in-memory only, so every restart cleared the Overview sparkline strip. Now the derived (t, cpu %, mem %, disk B/s, net B/s) points are written to the primary vhost's ozone database every minute under admin.history.host.v1 and loaded back at start-up so the strip picks up where the previous run left off. Only the derived form is persisted, not the raw /proc snapshots. A run of 720 samples at 5 s spacing stores as about 30 KB, the page load cost of the dashboard itself is unaffected, and fe2o3_sys does not need to learn ToDat/FromDat for structures the dashboard will never deserialise individually. HostSampler grows a persisted: Vec<DerivedHostPoint> slot, a derived_history() helper and a merged_derived_history() helper that stitches the persisted prefix onto the live ring without duplicating any sample that is still in live memory. The render_host_json handler switches to the merged view, so the Overview strip and any /admin/host.json consumer sees a continuous series across restarts. Uses the primary vhost's database as the persistence target: single source of truth, no extra config knob, honours the primary/secondary model. Traffic recorder and the broader admin-state persistence story land in a subsequent commit.

168 days agor1870400018:10981replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

26 operations, since the previous mark · compare with the head
+fe2o3_steel: Overview sparkline strip + auto-refresh.

Overview now carries a four-card sparkline strip (CPU busy, memory used, disk I/O, network throughput) driven by a new /admin/host.json feed that serialises the host sampler history with rate-based deltas between adjacent pairs. Cards show the latest value as a headline and a one-hour uPlot chart below. Traffic view gains a sibling /admin/traffic.json feed returning the counters, chart series and recent-requests list. The existing inline JSON blob still drives first paint for zero RTT; subsequent updates flow through the JSON feed with chart.setData() and DOM textContent updates. No full-page reloads. Shared AUTO_REFRESH_JS polls every 5 s, calls whichever refresh hook the current page exposed on window, and pauses while the tab is hidden. Overview and Traffic both include it; other views are synchronous snapshots and can adopt the same pattern when needed. Drops the old chip-row host strip and its format_load helper. The new strip covers the same metrics plus disk and network, with historical context you can actually see moving.

168 days agor1870400018:10954replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

23 operations, since the previous mark · compare with the head
+fe2o3_steel: wire AddressGuard into the TCP accept path.

Every incoming TCP stream is now fed through a shared SteelAddressGuard before the TLS handshake. Blacklisted attackers cost the server nothing beyond a SYN/ACK. The guard is held in AdminState so the accept loop and the dashboard read the same instance. Dashboard gains a Security view: a four-chip count row (monitor / throttle / blacklist / whitelist), an ordered table of observed addresses with inline whitelist / blacklist / reset buttons, and a manual blacklist form. Mutations require dashboard.admin and append a line to admin-audit.log under dashboard.guard.{whitelist, blacklist,unblock} verbs. View is gated on dashboard.view like every other dashboard page. Guard defaults tuned for HTTP bursts: 50 rps, 100 ms throttle spacing, 60 s base cooldown with 240 s of jitter, 6 throttle episodes before auto-blacklist, 16 shards, 64-sample per-address ring.

168 days agor1870400018:10930replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

29 operations, since the previous mark · compare with the head
+fe2o3_net: lift generic AddressGuard out of fe2o3_shield.

The rate-limit/throttle/blacklist state machine is now a transport-agnostic type in fe2o3_net::guard::addr, usable by Steel, SMTP, DNS or anything else that wants per-IP accept/drop decisions. Shield keeps only its handshake sequence check, layered on top of the generic guard via update_log. Also removes the compile-time R (max-rate) generic -- the runtime arps_max field was always the authoritative setting -- and adds a deterministic time-based sunset jitter so no rand dependency is needed in fe2o3_net. Public API: check / peek / whitelist / blacklist / unblock / counts / snapshot, plus the update_log low-level primitive for callers that need to compose extra checks under the same shard lock. Five unit tests cover the state transitions.

168 days agor1870400018:10900replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

38 operations, since the previous mark · compare with the head
+fe2o3_steel: dashboard visual phase 1 -- uPlot charts + fe2o3 mark.

Three separate but coupled additions, all in service of the traffic view's uPlot time-series chart: - TrafficRecorder gains a bounded history ring. New public constants DEFAULT_HISTORY_CAPACITY (720 slots, one hour at 5 s spacing) and DEFAULT_SAMPLE_INTERVAL_SECS (5 s) match the host sampler added alongside. New TrafficSample struct captures (when_secs, total, by_status) per tick; the recorder's history RwLock<VecDeque<TrafficSample>> is populated by sample_now() and read whole via history_snapshot() for the dashboard to render. Field names in TrafficRecorder were re-aligned when history/history_capacity were added so the column of ':' declarations stays tidy; the substantive diff is the two new fields, the two new methods, and the two new constants. - uPlot v1.6.32 vendored under assets/ as uplot.js (~51 KB) and uplot.css (~2 KB). MIT licensed. Inlined verbatim into the traffic page via include_str! and a `upload_head_html` helper in assets.rs -- keeping the asset pipeline trivial (no extra route, no cache headers, no authenticated vs unauthenticated split for static files). - fe2o3_logo.svg (text-right variant from the Hematite asset tree) shipped as the second admin-side logo file. Used by the sidebar brand block; the Oxedyne umbrella mark added in the preceding commit stays in the header. The upshot is that /admin/traffic can render an over-time request-rate chart, coloured by status class, pulling from the TrafficRecorder history ring. The renderer change itself, and the `sampler task` spawn block that keeps the ring populated, both land with the host sampler commit; this commit is just the pieces that make that renderer possible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10861replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

19 operations, since the previous mark · compare with the head
+fe2o3_steel: dashboard upgrade -- DB detail, host sampler, shell port.

Three coupled pieces land together because they all touch the dashboard and exercise the new fe2o3_sys crate from the previous commit. ## Database detail view (ozone_view.rs + style.css) /admin/database (renamed from /admin/ozone to match the mockup IA) now accepts a ?key=<urlencoded> query parameter. When supplied, handle_get calls Database::get for that key and marshals the outcome into a non-generic DetailView + DetailOutcome pair so the renderer does not need to carry the UID generics. Three outcomes are rendered distinctly: - Found -> zone, JDAT-encoded value (via Display), meta time (unix seconds) and meta user. - Missing -> "not present" notice. - Error -> short user-facing message (full structural error goes to the server log). The list view becomes a two-column .ozone-split: the key table on the left carries an href on every row that preserves the current prefix + limit and adds the clicked key, so selection round-trips through a same-page refresh. The .ozone-detail aside on the right holds the panel; a small CSS block at the bottom of assets/style.css handles the split, the selected row highlight and the detail field layout. url_encode() added next to the existing url_decode() so the anchor hrefs are RFC 3986 safe. ## Host sampler (host_sampler.rs + wiring) New HostSampler mirroring TrafficRecorder: bounded ring of 720 slots (one hour at the 5 s sample interval), Arc-shared between the server and the dashboard. Background task spawned from Server::start (next to the traffic sampler) calls Snapshot::sample() on every tick via fe2o3_sys; the dashboard reads the latest entry when rendering the Overview chip row. AdminState gains a host_sampler field; AdminState::new takes it as an additional parameter. AppShellContext constructs a fresh HostSampler alongside the TrafficRecorder and passes both Arcs through so Server::start can reach the sampler via self.context.admin_state. On first paint (briefly, before the sampler task has produced a reading) the Overview renders a "sampler is warming up" notice. Once the ring has a sample, the chip row shows: - Load (1/5/15 min) - Memory used fraction and total MiB - Steel process RSS and thread count - Host uptime in hours Rate-based figures (CPU busy percentage, disk/network bps) will fold in when the strip moves from chips to sparklines; that is a follow-up commit once a /admin/host.json endpoint and an inline uPlot builder are in place. ## Mockup shell port (assets.rs, style.css, logos, font) Chrome around the dashboard updated to match the approved static HTML mockup: - Oxanium variable font (43 KB TTF) served verbatim under /admin/assets/oxanium.ttf via a new pre-auth route in handler.rs (so the login page picks it up too). Cached for 24 hours via Cache-Control so the browser keeps one copy across navigations. CSS @font-face in style.css references the same route rather than a data URI. - h1 and h2 switched to Oxanium. h2 moves to uppercase-small with a bottom border so section headers read clearly. - Header brand uses the Oxedyne wordmark + mark (oxedyne_logo .svg) in place of the previous fe2o3 mark. The Fe2O3 wordmark moves to a new .sidebar-brand block at the top of the nav sidebar. The page heading on /admin/database carries the Ozone mark inline via a .heading-logo span. - NAV renamed: "Home" -> "Overview", "Ozone" -> "Database", href updated accordingly. Old /admin/ozone URL removed (no bookmarks to break; the tool is not yet in production use). Rename propagates into app/https.rs and srv/admin/ local_listener.rs dispatchers. - render_layout restructured so the sidebar brand block sits above render_nav output inside a single <nav class="sidebar">, instead of render_nav emitting the <nav> wrapper itself. 17 unit tests pass (9 fe2o3_steel lib + 8 fe2o3_sys). The tests/main.rs integration harness has a pre-existing broken AppWebHandler::new call-site unrelated to this change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10841replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

240 operations, since the previous mark · compare with the head
+fe2o3_sys: new crate for /proc host statistics.

Introduces a new Hematite crate that reads host resource counters directly from /proc pseudo-files on Linux, without pulling in a third-party crate (sysinfo, procfs, etc). Every metric module is a short pure parser: - cpu: aggregate CpuTimes from /proc/stat, plus a busy fraction helper that takes two snapshots and returns the (1 - idle_delta/total_delta) fraction. - mem: MemInfo from /proc/meminfo with total, free, available, buffers, cached, swap; used() follows the total - available convention so it does not double-count page cache. - load: LoadAvg from /proc/loadavg (1/5/15 min and the runnable/total task counter). - uptime: Uptime from /proc/uptime (wall seconds and the summed idle seconds across every CPU). - disk: DiskStats from /proc/diskstats with per-device cumulative counters (reads, writes, sectors, io_ms) and a deltas() helper that turns two samples into per-device bps + utilisation. - net: NetStats from /proc/net/dev with per-interface rx/tx bytes, packets, errors, drops and a deltas() helper mirroring disk. - proc_self: ProcSelf from /proc/self/status (RSS, VSize, peak, threads, voluntary/involuntary ctxt switches). - snapshot: Snapshot aggregates every module into a single reading and delta() derives rate-based figures. Error handling follows the project convention: res!, err!, ok!, Outcome. No unwrap, no unsafe, no ? operator. Every parser names the failing field in its error so a broken /proc line is visible in the log. Integration tests use fixed sample bodies copied from a real machine so the parsers are exercised deterministically regardless of the host the test runs on. 8 tests pass. Wired into the workspace umbrella via a new \`sys\` feature alongside the existing per-crate features. Downstream consumers will follow in the fe2o3_steel commit that adds the admin dashboard's host sampler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10600replica 1870400018

written by Jason Hoogland <hoogland@gmail.com>

30 operations, since the previous mark · compare with the head
+fe2o3: clear all warnings on a release build of an elearnity-flavoured Steel binary.

Seven warnings used to print on every release build of the elearnity steel binary: - fe2o3_crypto/src/pqc/saber.rs: four `mismatched_lifetime_syntaxes` on the Iter<u8> return types of the four `iter()` methods on the SABER key/ciphertext types. The lifetimes were elided in some arguments and hidden in others; replaced each with the explicit `Iter<'_, u8>` form the compiler suggests. - fe2o3_net/src/smtp/client.rs: unused `self` import on `tokio_rustls::rustls::{self, ...}`. Nothing under this use ever qualified with `rustls::`, so the `self` was dead weight. - fe2o3_net/src/smtp/cmd.rs: unused `dns::Fqdn` import. The HELO argument is handled as a raw String per RFC 5321 4.1.1.1, so Fqdn is not referenced from this module. - fe2o3_steel/src/srv/server.rs: unused `mut` on the accept loop's `(stream, src_addr)` binding. `TcpStream::peek` takes `&self`, and the subsequent `tls_acceptor.accept(stream)` and `handle_redirect(stream, ...)` calls consume the binding by value, so no callsite ever needs mutability. Workspace now compiles release with zero warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10569replica 1870400018

written by h00gs <hello@oxedize.com>

12 operations, since the previous mark · compare with the head
+fe2o3_steel: localhost plain-HTTP admin listener.

Adds an optional second HTTP listener bound to 127.0.0.1:<port> that serves the /admin/* routes without TLS. Use case: emergency or operator-only access via SSH tunnel when the public TLS chain is unhealthy (expired cert, broken ACME, network reconfiguration) or when the operator simply prefers to keep the dashboard off the public path. Security model: - Bound to loopback only. There is no "expose to network" knob; reaching the listener requires shell access to the host. - Same auth gate as the public path. Sessions are decoded with the same AdminState::session_enc; cookies issued via either path work via the other. - No vhost dispatch. Every request is dispatched to the dashboard handler regardless of Host header. Non-/admin paths return 404 with a deliberate message ("the localhost listener serves /admin only") so operators do not accidentally use it as a general HTTP server. - Ozone view falls back to the first registered vhost db. With no per-vhost routing on the local listener, /admin/ozone uses vhost_dbs.values().next() as the default; operators who want a specific vhost's ozone use the public path. New ServerConfig field admin_local_port: u16, default 0 (disabled). When non-zero, Server::start spawns a tokio task running run_admin_local_listener. The listener is implemented as an impl ServerContext method in srv/admin/local_listener.rs (mirroring how handle_https is defined in srv/https.rs from another file). To reach it, ServerContext gains an admin_state: Option<Arc<AdminState>> field alongside the existing traffic field. Both are populated by start_server from the same Arcs that AppWebHandler holds, so the dashboard handler called from the local listener and the dashboard handler called from the public TLS pipeline share one live state. Traffic recorder is wired in too: every request through the local listener writes a RequestRecord under the synthetic vhost name `_admin_local` so the dashboard's traffic view can distinguish local from public traffic. All 9 dashboard unit tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10556replica 1870400018

written by h00gs <hello@oxedize.com>

24 operations, since the previous mark · compare with the head
+fe2o3_steel: dashboard admin management UI.

Adds /admin/admins, the dashboard's wallet admin management view. Lists every wallet admin with name, expiry and scopes; exposes an "add" form for enrolling a new admin and a "remove" button per row; renders flash banners above the list with the outcome of the most recent mutation. Authorisation requires both: a dashboard scope (dashboard.view OR dashboard.admin) the legacy admin scope (admin) A signed-in principal who does not hold `admin` sees a "forbidden" variant of the page rather than a redirect to login. This matches the CLI rule that admin verbs are gated on the `admin` scope, and the architectural decision in project_steel_proxy_architecture.md that dashboard scopes and the CLI `admin` scope are independent: the dashboard scope controls whether you can see the dashboard at all; the `admin` scope controls whether you can manage other admins anywhere. To make Wallet::enrol and Wallet::save reachable from the dashboard handler, AdminState gains two fields: master_key (needed by enrol to wrap the new admin's key) and wallet_path (needed by save). Both are populated in start_server from self.db_enc_key and app_const::WALLET_NAME respectively. The master key was already derivable from the unlocked wallet at this point in the boot path; storing a copy in AdminState avoids re-prompting the operator on every dashboard mutation and keeps the in-memory secret footprint the same -- it is already held in AppShellContext::db_enc_key for the same reason. Two new audit verbs in srv/admin/audit: VERB_DASHBOARD_ADMIN_ADD VERB_DASHBOARD_ADMIN_REMOVE Every add and remove writes one line to admin-audit.log under the actor's name, with the same line format CLI admin verbs already use. Failures (validation, enrol error, save error, remove error) write `err` lines with reason= details. Cosmetic: the JS confirm() on the remove button asks "Remove admin {name}?" before posting. SameSite=Strict on the session cookie protects against cross-site form submission; the confirm() is just to stop fat-finger removals. All 9 dashboard unit tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10531replica 1870400018

written by h00gs <hello@oxedize.com>

26 operations, since the previous mark · compare with the head
+fe2o3_steel: dashboard UI assets, layout, traffic view.

Replaces the inline placeholder HTML in handler.rs and ozone_view.rs with a shared Hematite-styled layout and a single embedded stylesheet. Also lights up the /admin/traffic route so the sidebar link is no longer a 404 and the live TrafficRecorder the request pipeline already feeds becomes visible. New module srv/admin/assets carries: - A 6 KB stylesheet at src/srv/admin/assets/style.css inlined via include_str! and embedded in every dashboard response inside a <style> tag. No external font load, no JavaScript framework, no preprocessor; the file is short enough that per-response cost is negligible. Inlining (rather than serving /admin/css/style.css from a separate route) keeps the asset pipeline trivial: no authenticated-vs-unauthenticated path split for static files, no separate disk directory to sync to a production host. - A render_layout helper that wraps a body fragment in the authenticated dashboard chrome: header bar with brand and signed-in user, sidebar with nav entries, main content panel. Sidebar entries highlight the current page via a `current` CSS class. - A render_login_layout helper for the unauthenticated login form (centred card, no sidebar). - A pub html_escape used by every render module. - A pub NAV table declaring the dashboard's nav entries (Dashboard / Traffic / Ozone / Admins / Sign out). Visual style follows the Hematite documentation conventions: - Primary red rgb(243, 60, 87) accents, links, buttons, header underline. - Primary blue rgb(171, 202, 222) inline code background. - Light grey rgb(240, 240, 240) panels, table headers. - Libertinus Serif (web fallback to Georgia, then Times) for body and headings, with small-caps bold H1 and italic grey H2 in keeping with the academic-technical-doc feel. Brand mark is text-only in v1 ("Hematite Steel" with `Steel` in the accent colour). A real SVG mark can drop in by replacing brand_html() in assets.rs. We intentionally do not pull from ~/usr/complement/projects/oxedyne/projects/fe2o3/doc/Hematite/ at compile time so fe2o3_steel builds without the complement tree being present. handler.rs and ozone_view.rs render functions are refactored to emit body fragments only and delegate the page chrome to render_layout. The duplicated local html_escape implementations are removed in favour of assets::html_escape. A shared html_response helper centralises the content-type wiring. New /admin/traffic route reads the shared TrafficRecorder from AdminState and renders: - Total request count since startup. - Per-status counter table. - Most recent 50 requests with method, vhost, path, status, duration in microseconds. Empty-state message when no requests have been recorded yet. To make the traffic view reachable from the handler, AdminState gains a `traffic: Arc<TrafficRecorder>` field that holds the same Arc the request pipeline writes to via ServerContext. The construction order in start_server is updated so the recorder is built before AdminState; both AppWebHandler (the dashboard side) and ServerContext (the request-pipeline side) end up sharing the same Arc. session::tests now constructs the test AdminState with a TrafficRecorder; all 9 dashboard unit tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10504replica 1870400018

written by h00gs <hello@oxedize.com>

52 operations, since the previous mark · compare with the head
+fe2o3_steel: ozone browser (read-only) for the admin dashboard.

Implements srv/admin/ozone_view as a generic helper that walks the current vhost's ozone database via the upstream Database::scan API, applies the requested prefix and limit filters, and renders the resulting key set as an HTML table. Routes: GET /admin/ozone[?prefix=<s>][&limit=<n>] The list view lives at /admin/ozone for the current vhost -- each vhost's dashboard sees its own ozone, so there is no cross-vhost path namespace to coordinate. Per-vhost isolation falls out naturally because the request hits one vhost's /admin and the per-vhost db that AppWebHandler::handle_get already receives is forwarded straight into ozone_view. Limits: DEFAULT_LIST_LIMIT = 500 default cap when ?limit= is omitted. MAX_LIST_LIMIT = 5000 hard ceiling on operator-supplied limits to stop ?limit=999999999 from materialising the whole database. Auth gate is the same as every other authenticated dashboard view: extract_principal checks the session cookie, dashboard scope is required, missing or rejected cookies 303 to /admin/login. Vhosts without a configured ozone database (pure-redirect vhosts, static-only vhosts) get a friendly empty-state page rather than a 500. Scan failures log at error! and show a generic "scan failed, check the server log" page so structural errors do not leak into the response body. Dispatch in app/https.rs::handle_get is now two-stage: paths beginning with /admin/ozone go to the generic ozone_view helper (which needs the per-vhost db typed parameters in scope), all other /admin paths go to the non-generic handler module. handler::extract_principal and handler::redirect_to_login are made pub so dashboard submodules share the same auth gate rather than each implementing cookie verification independently. v1 list view shows keys only -- scan returns Dat::Empty values and the renderer is intentionally not generic over UID byte length. A per-key detail view (calling Database::get to materialise the value on demand) lands in a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10451replica 1870400018

written by h00gs <hello@oxedize.com>

6 operations, since the previous mark · compare with the head
+fe2o3_steel: lift audit log into srv/admin and audit dashboard actions.

The admin audit log used to be a private function inside app/repl.rs, reachable only by the CLI admin verbs. Move it to srv/admin/audit.rs as a public helper so the dashboard handler in the srv layer can write to the same file with the same line format and the same failure-tolerant semantics. The line format is unchanged: <unix_seconds> <admin> <verb> <result> <detail> so any existing log scraping continues to work. Verb constants for the dashboard side are declared in audit.rs to prevent spelling drift across handler call sites: VERB_DASHBOARD_LOGIN VERB_DASHBOARD_LOGOUT ADMIN_AUDIT_LOG_NAME also moves to srv/admin/audit.rs and is removed from app/constant.rs. handler::handle_login now writes one audit line per outcome: ok reason omitted, scopes=... in detail err missing_passphrase_field err verify_structural_error err bad_credentials err no_dashboard_scope The actor name is the unlocked admin where known and (anon) otherwise. The HTTP response remains generic regardless of outcome: the audit log is the only place where bad-credentials and no-dashboard-scope can be told apart. handler::handle_logout writes one audit line under the actor's name when a valid session cookie is present, or (anon) otherwise. app/repl.rs is updated to call audit::append from its admin verb sites; the original private audit_log function is removed. All 9 dashboard unit tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10444replica 1870400018

written by h00gs <hello@oxedize.com>

28 operations, since the previous mark · compare with the head
+fe2o3_steel: validate dashboard session cookie + record traffic.

Threads request HeaderFields into srv/admin/handler so the home view can extract and verify the steel_admin_sess cookie via session::decode_session. Verified principals are checked against can_view_dashboard before the page renders; any failure (no cookie, tampered cookie, expired cookie, missing dashboard scope) 303-redirects to /admin/login. The placeholder home page now shows the signed-in admin name and scope list. Adds an optional Arc<TrafficRecorder> to ServerContext so the HTTPS request pipeline in srv/https.rs can emit a RequestRecord once the response has been written. The recorder is the same Arc the dashboard handler reads, so the dashboard sees live traffic without any per-vhost coordination. Capture method, path, status, peer, response body length, and full handle-to-write duration in microseconds; recording is best-effort and never breaks the request itself. start_server now passes the shared TrafficRecorder into both AppWebHandler (where the dashboard reads from it) and ServerContext (where the request pipeline writes to it). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10415replica 1870400018

written by h00gs <hello@oxedize.com>

28 operations, since the previous mark · compare with the head
+fe2o3_steel: wire /admin route, share wallet via Arc<RwLock>.

Wraps AppShellContext::wallet in Arc<RwLock<Wallet>> so the dashboard handler running inside the HTTPS server task and the REPL admin verbs running on the operator's terminal share one live wallet instead of two divergent snapshots. CLI sites in repl.rs (secrets create/recover, wallet migrate, admin add/remove/passwd/list) take read or write guards as needed; secrets recover clones the encrypted-secret Dat out of the lock before its interactive password prompt so locks are not held across user input. start_server now constructs an AdminState (wallet handle + AES-256-GCM session cipher derived from the master key via SHA3-256 with the "steel.admin.dashboard.session.v1" info string) and a TrafficRecorder (default 10k-entry ring) once at startup. Both are threaded into AppWebHandler via two new optional fields. Per-vhost handlers each get a clone of the same Arc, so dashboard state is host-wide rather than per-vhost. AppWebHandler::handle_get / handle_post now check for "/admin" and "/admin/*" before any webhook, API or static route lookup. When admin_state is set, the path is dispatched to srv/admin/handler; when it is None the path returns 404 so the response does not leak the existence of an admin endpoint. srv/admin/handler.rs is now a real module rather than a placeholder: - GET /admin/login renders an HTML login form with vanilla CSS using the Hematite red accent. - POST /admin/login URL-decodes the `passphrase` field, calls auth::verify_passphrase, and on success issues a session cookie via session::encode_session and 303-redirects to /admin. - GET /admin/logout sets a Max-Age=0 session cookie and redirects to /admin/login. - GET /admin placeholder authenticated landing page (full cookie validation arrives in the follow-up commit that threads request headers into the admin handler). The session cookie is HttpOnly, Secure and SameSite=Strict, scoped to /admin. Login error messages are deliberately generic so the response cannot distinguish "no admin" from "wrong passphrase" from "no dashboard scope". AdminState and TrafficRecorder gain #[derive(Debug)] so AppWebHandler can keep its #[derive(Debug)]. All 9 existing dashboard unit tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10386replica 1870400018

written by h00gs <hello@oxedize.com>

48 operations, since the previous mark · compare with the head
+fe2o3_steel: admin dashboard scaffold (auth, session, traffic recorder).

Scaffold for the Steel admin dashboard under src/srv/admin/. Submodule declared in srv/mod.rs alongside the existing api / cert / cfg / mail / webhook layer modules. Public scope vocabulary defined in mod.rs as constants: - SCOPE_ADMIN ("admin"): existing CLI scope, also gates dashboard admin-management UI when present alongside a dashboard scope. - SCOPE_DASHBOARD_VIEW ("dashboard.view"): read-only dashboard access. - SCOPE_DASHBOARD_ADMIN ("dashboard.admin"): full dashboard access. - SCOPE_WILDCARD ("*"): matches every verb. The admin command's --scopes help text in app/syntax.rs is updated to document the new vocabulary. AdminPrincipal type carries the authenticated identity (name, scopes, expires_at) and exposes has_scope / can_view_dashboard / can_admin_dashboard / can_manage_admins gating helpers. state.rs defines AdminState { wallet: Arc<RwLock<Wallet>>, session_enc: EncryptionScheme }. The session AES-256-GCM key is derived from the wallet master key via SHA3-256 with a domain-separated info string ("steel.admin.dashboard.session.v1") so it is cryptographically distinct from the ozone at-rest key even though both ultimately trace to the same wallet unlock. session.rs implements stateless signed session cookies. The plaintext record is a length-prefixed binary envelope (name, scopes, expires_at) encrypted with AES-256-GCM and base2x-encoded with a v1. version prefix. Decode verifies the AES-GCM tag, the version prefix, and the expiry. Sliding-expiry helper refresh_principal advances the deadline on every authenticated request. Tests cover round-trip, tamper-detection (AES-GCM tag), expiry rejection, and version mismatch. auth.rs implements verify_passphrase. Login reuses the existing Wallet::unlock path (which iterates admins and handles per-admin expiry), then enforces dashboard-scope gating on top. Returns a LoginOutcome enum so the handler can distinguish bad credentials, correct credentials without dashboard scope, and successful login without leaking detail to the client. traffic.rs implements TrafficRecorder, a thread-safe ring buffer of recent RequestRecord snapshots plus per-vhost / per-status counters. Bounded ring (default 10k entries), independent RwLocks for ring vs counters so dashboard reads do not contend with request-pipeline writes, atomic total counter for lock-free reads, MAX_PATHS_PER_VHOST cap with an _other overflow bucket so probing unique URLs cannot blow memory. The shape is chosen to feed a future fe2o3_net::guard extraction (Steel's hardening phase) from the same counters, not just the dashboard. Tests cover record/recent ordering, ring eviction, counter aggregation, path bucket saturation, and limit honouring. Placeholder modules with module-level doc comments only, to be filled in by subsequent commits: - ozone_view.rs: read-only ozone browser handlers using the new Database::scan API landed upstream. - assets.rs: embedded HTML / CSS / JS / Hematite logo via include_str! / include_bytes!. - handler.rs: HTTP dispatcher mapping /admin/* paths to the views above and gating them on the session cookie. No request-pipeline wiring yet; the admin module is reachable as a library surface but no listener routes /admin requests to it. That plumbing lands with the route-wiring commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10337replica 1870400018

written by h00gs <hello@oxedize.com>

18 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: Database::scan, plaintext keys on disk, format v2.

Add a Database::scan trait method on fe2o3_iop_db that walks the database and returns (key, value, meta) triples, optionally filtered by prefix and capped by limit. Implementation in fe2o3_o3db_sync dispatches one ScanRequest per zone to its first igbot, which walks the zone's .ind files, deduplicates by raw key bytes (newest write wins), elides internal chunk entries, and returns the live entries. Coordinator merges per-zone results and applies the global limit. Stop hashing the user's key on the way into the write path. Previously api.rs::keygen wrapped the routing hash in a fixed-width Dat::BU* and returned it as the on-disk key form, with the side effect that the caller's original Dat key was discarded. The routing hash is still computed for cbot/zone selection but no longer becomes the stored key. The kbuf that flows through the rest of the write path, into the cache, and onto disk is now the original Dat::as_bytes() form, so a scan recovers the user's keys via Dat::from_bytes. Bump OzoneConfig::format_version from 1 to 2. v1 databases that stored hashed keys cannot be migrated and must be recreated. The format check in cfg::check_and_fix refuses a stale config with a clear error pointing the operator at recreation. Scan v1 returns Dat::Empty as the value for every entry; values are fetched on demand via get() once the caller selects a key. Reading values from disk inside scan is a v2 concern. New integration test tests/scan.rs populates 18 keys across three prefixes and verifies scan-all, prefix scan, limit, and overwrite-then-rescan. test/setup.rs now pre-wipes config.jdat in wipe mode so format-version bumps do not strand the existing test suite on stale configs. fe2o3_shield and fe2o3_steel get one-line literal updates to their OzoneConfig constructions because the new format_version field is mandatory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

168 days agor1870400018:10318replica 1870400018

written by h00gs <hello@oxedize.com>

33 operations, since the previous mark · compare with the head
+fe2o3_net + fe2o3_steel: thread request header fields through to handlers.

API and webhook handlers previously had no way to read incoming request headers. The only information plumbed to them was `loc` (path + query string + parsed fields), the body bytes, the TLS client, and a connection id -- but not `Accept-Language`, `Authorization`, `Stripe-Signature`, or anything else the caller might have put in a header. This commit threads `Arc<HeaderFields>` from the HTTP request parser in `fe2o3_steel::srv::https` and `srv::smtps` all the way through `WebHandler::handle_{get,post}` in `fe2o3_net::http::handler`, `AppWebHandler` in `fe2o3_steel::app::https`, and into both `WebhookHandler::handle` (`srv::webhook.rs`) and `ApiHandler::handle` (`srv::api.rs`). Call-site wrapping: before the GET/POST method switch, the call site clones `request.header.fields` into a fresh `Arc` once so that both branches and any cascade from webhook → API → upstream can share the same header table without repeated cloning. The clone keeps the original `request.header.fields` in place for the existing POST-branch `Content-Type` extraction that writes into `loc.data`. Dispatch signatures: both `srv::api::dispatch` and `srv::webhook::dispatch` grow a new `req_headers: &HeaderFields` parameter and pass it straight through to the selected handler. Trait impls: the only existing `WebhookHandler` implementor is elearnity's `StripeBookvault`, which takes `&HeaderFields` with a leading underscore for now (no in-tree reader yet, but the hook is there for when the handler starts verifying the Stripe signature header or branches on Content-Type). Steel prelude additions: `HeaderFields`, `HeaderFieldValue`, `HeaderName`, `HttpHeadline`, `HttpMethod`, `HttpLocator`, `HttpMessage` and `HttpStatus` are now re-exported so app handlers can read and construct request / response objects without a direct `fe2o3_net` dependency.

169 days agor1870400018:10284replica 1870400018

written by h00gs <hello@oxedize.com>

70 operations, since the previous mark · compare with the head
+fe2o3_jdat: RFC 8259 string escape decode + encode round-trip.

Previously the decoder pushed every character inside a quoted string literal verbatim, and the encoder emitted every string value verbatim. Neither touched `\` specially. The practical effects: - `"doesn\u2019t"` decoded to the eleven-character Rust string `doesn\u2019t` (the backslash-u escape survived as literal characters) rather than to the eight-character `doesn\u{2019}t` with a real U+2019 RIGHT SINGLE QUOTATION MARK. - `"foo\"bar"` terminated the string early at the unescaped `\"`, producing two adjacent strings instead of one. - `"line1\nline2"` decoded to the twelve characters `line1\nline2` instead of a ten-character string with an embedded newline. - On the encode side, a string value containing `"` or `\` or any control character was written out verbatim, producing invalid JSON that no other parser could read. Both sides now conform to RFC 8259 §7. Decoder ------- `DecoderState` gains a `string_escape: StringEscape` state machine, driven by a new `handle_string_escape` associate function called at the top of the outer loop whenever `quote_protection != Quote::None`. States: - `None` -- normal character slurping inside a string literal; `\` transitions. - `Backslash` -- previous char was `\`; current char is the escape type (`"`, `\`, `/`, `b`, `f`, `n`, `r`, `t`, `u`). - `Unicode { digits, acc }` -- collecting four hex digits after `\u`. On completion, a BMP code point is pushed directly, a high surrogate transitions into `SurrogateBackslash`, and a bare low surrogate is an error. - `SurrogateBackslash { high }` / `SurrogateU { high }` -- two transitional states waiting for `\u` to start the low half. - `LowSurrogate { digits, acc, high }` -- collecting the low half's four hex digits. On completion the pair is combined into a supplementary plane code point (`0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)`) and pushed. Invalid sequences (unknown escape type, non-hex digit, bare low surrogate, high surrogate not followed by `\u`, invalid code point) produce clear decode errors naming the offending character and its file position via the existing cursor error chain. The state machine runs ahead of the `"` / `'` quote-terminator matching so that `\"` and `\'` inside a string are treated as escapes rather than as the end of the string. A small `hex_digit_value` helper is also added, used by both the BMP and surrogate-pair branches. Encoder ------- A new free function `escape_json_string` in `string/enc.rs` handles the inverse translation. It escapes: - `"` -> `\"` - `\` -> `\\` - `\u{0008}` -> `\b` - `\u{000C}` -> `\f` - `\n` -> `\n` - `\r` -> `\r` - `\t` -> `\t` - other C0 (U+0000..U+001F) and DEL (U+007F) -> `\u00xx` Everything else -- including all non-ASCII UTF-8 -- is passed through unchanged. A right single quotation mark stays as `’`, an emoji stays as itself. Decoded-then-re-encoded strings are therefore a fixed point after the first pass, which means tools that edit a JSON file through `Dat::decode_string` + `encode_string_with_config` no longer churn between escape forms each run. The `Self::Str(s)` arm of `recursive_encode` now routes through `escape_json_string(s)` instead of `fmt!("\"{}\"", s)`.

169 days agor1870400018:10213replica 1870400018

written by h00gs <hello@oxedize.com>

6 operations, since the previous mark · compare with the head
+fe2o3_jdat: Dat::map_put + use_ordmaps honoured at top-level decode.

Two small but load-bearing gaps uncovered while adding a pretty-print round-trip to the elearnity `sync-title` CLI. 1. `Dat::map_put(&mut self, key: Dat, val: Dat) -> Outcome<Option<Dat>>`. Variant-agnostic insert/update on either `Dat::Map` or `Dat::OrdMap`. On an `OrdMap`, an existing entry is replaced in place so the original `ord` (and therefore position) is preserved; a new entry appends at the end by picking the next available `ord` slot via `max(existing) + OMAP_ORDER_DELTA_DEFAULT`. Refuses if two `MapKey`s share the same dat -- that is the same invariant `map_get` and `map_remove` already enforce. Without this, downstream code that wanted to mutate a round-tripped map had to: - manually destructure `Dat::Map(m)` / `Dat::OrdMap(m)` - for `OrdMap`, find the existing `MapKey`, clone it, remove it, reinsert with the same `ord` - for new keys in `OrdMap`, pick the next `ord` manually which is three-quarters of a helper sitting in every caller. 2. `DecoderConfig::use_ordmaps` now applies to the top-level `{...}` as well as nested maps. The recursive decoder set `kind_outer = Kind::OrdMap` only in the nested branch at `string/dec.rs:1542`. The top-level branch a few lines above only set `molecular_capture = Some(Map)` and left `kind_outer` at its default (`Kind::Unknown`), so the root-level map always landed as `Dat::Map` regardless of `cfg.use_ordmaps`. The final `}` handler then committed it as a `Dat::Map` and callers lost key order on the outermost layer despite asking for `use_ordmaps = true`. Symptom: round-tripping a JSON file through `decode_string_with_config` + `encode_string_with_config` alphabetised the top-level keys while preserving every nested level's order. Fixed by mirroring the nested-branch's `kind_outer` assignment in the top-level branch, guarded by the same `!explicit_kind` check.

169 days agor1870400018:10206replica 1870400018

written by h00gs <hello@oxedize.com>

2 operations, since the previous mark · compare with the head
+fe2o3_steel: expose build_outbound_tls_client and OptionRefVec via prelude.

Both changes unblock app-side CLI subcommands contributed through `AppExtension::extend_syntax` + `dispatch_cmd` that need to make outbound HTTPS calls from inside a synchronous handler. 1. `build_tls_client` promoted from a private method on `AppShellContext` in `app/server.rs` to a free `pub fn build_outbound_tls_client() -> Outcome<Arc<ClientConfig>>` at the module top level. The one internal caller in `start_server` now goes through the free function. No behavioural change -- the CA-bundle search (Debian/Ubuntu, Fedora/RHEL, Alpine/macOS) and PEM parse loop are unchanged. App extensions can now build an outbound TLS client for one-shot CLI work without duplicating the logic. 2. Added `build_outbound_tls_client` to the Steel prelude so apps do not need to reach into `crate::app::server` directly. 3. Added `OptionRefVec` (from `oxedyne_fe2o3_syntax::opt`) to the Steel prelude. The trait provides the `.with_len(n)` method on `Option<&Vec<Dat>>` that extension dispatchers need when pulling argument values out of `MsgCmd::get_arg_vals`. Without the re-export each app crate would have to pull fe2o3_syntax in as a direct dependency just to import this one trait.

169 days agor1870400018:10203replica 1870400018

written by h00gs <hello@oxedize.com>

5 operations, since the previous mark · compare with the head
+fe2o3_jdat: JSON compatibility fixes + map_get_* navigation helpers.

Three changes pulled from exercising the crate via the elearnity `sync-title` CLI subcommand, which decodes BookVault /v3/Title responses into `Dat` and writes mutated item JSON back to disk. Every one of these was a real bug encountered on real input, not a hypothetical. 1. Decoder now accepts U+0009 tab as whitespace. JDAT bills itself as a typed superset of JSON, and RFC 8259 §2 explicitly lists HT (tab) alongside SP / LF / CR in the whitespace production, so a JSON file with tab indentation should round-trip through `Dat::decode_string` unchanged. The decoder previously raised: "Escaped tab characters are prohibited." at every tab, which broke any hand-edited JSON file that preferred tabs for indentation. `string/dec.rs` now treats `\t` the same as `' '` / `\n` / `\r`. 2. Decoder now accepts bare `null` as a value via keyword alias. `Dat::Opt(Box::new(None))` is the JDAT analogue of JSON `null`, and the decoder already accepted the kindicle form `(none)` and the shorthand keyword `none`. The RFC 8259 keyword `null` was not recognised, so any JSON value `null` in a map or list broke decoding. `kind.rs` now aliases `"null"` to `Kind::None` in `FromStr`, alongside the existing `"none"` arm. No new variant, no surface area -- just the keyword table. 3. Decoder now handles `""` (empty quoted string) as a map / list value. `Slurp` tracks the captured text in `self.s` and a separate `is_string: bool` flag set when quotes are seen. At the `}` / `]` / `,` boundaries, the decoder decided whether to flush a pending value with `slurp.len() > 0`, which is false for an empty quoted string -- so `{"key": ""}` decoded as an unpaired key and errored out. Added a `Slurp::has_content()` method that returns `self.is_string || !self.s.is_empty()`, and replaced every value-flush check with it. Eight call sites updated. 4. Five new `map_get_*` helpers on `Dat` (in `map.rs`). The idiomatic pattern for pulling a typed value out of a `Dat::Map` was: let n = res!( res!(m.map_get_must(&dat!("Weight"))) .get_i64() .ok_or_else(|| err!("..."))); Verbose at every field access. Added thin wrappers that combine the lookup with the type-specific getter: pub fn map_get_string(&self, key: &Self) -> Outcome<String> pub fn map_get_i64 (&self, key: &Self) -> Outcome<i64> pub fn map_get_f64 (&self, key: &Self) -> Outcome<f64> pub fn map_get_map (&self, key: &Self) -> Outcome<&Self> pub fn map_get_list (&self, key: &Self) -> Outcome<&Vec<Dat>> `_i64` / `_f64` delegate to the existing `get_i64` / `get_float64` coercing getters, so any signed or unsigned integer variant converts to i64 and any numeric variant (including BigInt / BigDecimal) converts to f64. `_map` and `_list` return references so callers can navigate deeper without cloning. Each returns a clear error message naming the offending key and its actual kind on type mismatch. No API breakage -- these are purely additive.

169 days agor1870400018:10197replica 1870400018

written by h00gs <hello@oxedize.com>

12 operations, since the previous mark · compare with the head
+fe2o3_crypto: untrack SABER static libs, guard build.rs against rerun + failure.

Two related fixes so a clean checkout on a machine with libssl-dev builds once and never re-runs the C step on subsequent cargo invocations. 1. build.rs emits cargo:rerun-if-changed=src/c and cargo:rerun-if-changed=build.rs so cargo does not rerun this build script on every invocation (which would re-run the ./build_all shell script under src/c every time and waste a few seconds per build). 2. build.rs guards the ./build_all call with an existence check against the selected scheme's .a file. If the file is already present the C build is skipped; if it is missing (fresh tree or someone deleted it) ./build_all runs, and if it still does not produce the .a file afterwards, the build script panics with a pointer to build.log and a hint that libssl-dev is a common cause. Previously a failing ./build_all would delete the tracked .a files and leave the linker to report a confusing "could not find native static library" error several compilation units later. 3. libfiresaber.a / liblightsaber.a / libsaber.a and build.log are no longer tracked in git -- they are build artefacts, not source, and keeping them tracked fought the regeneration in build.rs on every build. A new .gitignore entry covers the three libraries; build.log was already matched by *.log but was tracked from before the ignore rule was added. Clean checkout on a new machine now needs libssl-dev installed once; the first `cargo build` runs ./build_all to produce the static libraries, and every subsequent build skips that step as long as the .a files are present and the C sources are unchanged.

169 days agor1870400018:10184replica 1870400018

written by h00gs <hello@oxedize.com>

7 operations, since the previous mark · compare with the head
+fe2o3_steel: AppExtension trait for app-side shell commands, webhook and API handlers.

Introduces a single extension surface for app crates that embed Steel, replacing the earlier `run_with_webhooks` entry point. An app now implements `AppExtension` and hands it to `run_with_extension`; Steel then uses the trait to populate the shell Syntax tree, build the webhook and API registries at startup, and dispatch shell commands it does not recognise. AppExtension trait (fe2o3_steel/src/app/ext.rs) ----------------------------------------------- Four default-method hooks, each optional: extend_syntax(Syntax) -> Outcome<Syntax> Contribute commands to the shell Syntax tree at startup. Commands added here show up in `./steel help` automatically. dispatch_cmd(cmd_name, &MsgCmd, &ShellConfig) -> Outcome<Option<Evaluation>> Called by the REPL when a parsed command name does not match any built-in. Returning `Ok(None)` lets Steel log "command not implemented"; returning `Ok(Some(eval))` claims the command. webhook_handlers() -> Vec<(String, Box<dyn WebhookHandler>)> Named handlers registered into the WebhookRegistry at startup. Matches the existing `webhook_routes` config shape. api_handlers() -> Vec<(String, Box<dyn ApiHandler>)> Named handlers registered into a new ApiHandlerRegistry at startup. Matches the new `handler` field on ApiRoute. A unit `NoExtension` implementation is provided for binaries that want the stock Steel behaviour. `run(NoExtension)` is the convenience path kept for the default `steel` binary. ApiHandler + registry (fe2o3_steel/src/srv/api.rs) -------------------------------------------------- New file mirroring the webhook handler pattern. The handler receives the full request surface (method, parsed HttpLocator, headers, body, shared outbound TLS client, and connection id) and must return an HttpMessage -- API requests always have a client expecting a response, unlike webhooks. ApiRoute now carries either an `upstream` field (proxied) or a `handler` field (in-process). The two are mutually exclusive, checked at config load. The HTTPS request path uses the registry for `handler` routes and the existing outbound proxy for `upstream` routes. GET is accepted on API routes as well as POST, so in-process handlers can implement GET-only endpoints (e.g. a geolocation lookup) without a separate dispatch. run_with_extension + built-in wiring ------------------------------------ `run_with_extension(impl AppExtension)` is the new entry point. The old `run_with_webhooks(WebhookRegistry)` path is removed (no backwards compatibility). The default `steel` binary calls `run(NoExtension)`, which in turn calls `run_with_extension` with the unit extension. Startup sequence: 1. Build the built-in Syntax via `app::syntax::build_syntax`. 2. Call ext.extend_syntax(s) to let the app add its own. 3. Start the REPL with the combined tree. 4. On each command, try built-ins first; on miss, call ext.dispatch_cmd and bubble up the Evaluation. 5. When the HTTPS listener binds, resolve each vhost's webhook_routes and api_routes against the registries built from ext.webhook_handlers() and ext.api_handlers(). Unknown names fail fast. syntax.rs, repl.rs: small extension points so the shell Syntax and dispatch loop accept commands coming from an AppExtension. tui.rs: threaded the AppExtension through the unlock + start-server path. Config shape (fe2o3_steel/src/srv/cfg.rs) ----------------------------------------- ApiRoute gained: - optional handler: Option<String> (name into ApiHandlerRegistry). - made upstream optional. - load-time check that exactly one of upstream / handler is set. - load-time {file:...} / {env:...} placeholder resolution on every header value and on every handler-config value, matching the existing behaviour for webhook routes. Intentional non-changes ----------------------- - webhook handler trait and registry are untouched; they continue to live in srv/webhook.rs and are just populated by the new ext.webhook_handlers() call instead of by a direct run_with_webhooks call site. - no new crate-level re-exports beyond what AppExtension needs; downstream crates continue to import oxedyne_fe2o3_steel::prelude::* as before.

169 days agor1870400018:10176replica 1870400018

written by h00gs <hello@oxedize.com>

83 operations, since the previous mark · compare with the head
+fe2o3_steel: revert Cargo.lock audit refresh to restore swc compatibility.

The refresh in c37553e bumped serde to 1.0.228, which dropped the serde::__private module still referenced by swc_common 5.0.1 and swc_config 1.0.0. Downgrading serde pulls indexmap/serde_json with it into a whack-a-mole dependency fight. Simplest fix is to restore the pre-refresh lockfile (serde 1.0.216), which tracks the last state known to build. The four audit advisories that the refresh closed are still open against this lockfile; they need a proper swc bump (or a vendored swc_common patch) before we can re-apply the refresh.

169 days agor1870400018:10092replica 1870400018

written by h00gs <hello@oxedize.com>

1 operation, since the previous mark · compare with the head
+fe2o3_crypto: admin --passwd verb; Cargo.lock refresh closes 4 audit advisories.

- Adds `Wallet::change_password(admin_name, master_key, new_password, kdf_name)` which replaces the named admin's wrap in place while preserving name, scopes and expires_at. Uses the master key the caller already holds from an earlier `unlock`, so no re-authentication prompt is needed. - Adds the corresponding `steel admin --passwd` / `-p` CLI verb in fe2o3_steel. Reads the caller identity from the startup unlock, prompts for a new password twice via `UserInput::create_pass`, calls `change_password`, saves the wallet, appends an `admin.passwd` record to the audit log. The running Steel process is unaffected -- only the next cold start picks up the new passphrase. - Adds `test_wallet_change_password` covering the round-trip: new password unlocks, old password no longer does, master key is identical across the rotation, scopes are preserved, unknown-name requests fail. - Runs `cargo update` across the workspace, which closes 4 RUSTSEC advisories without touching any `Cargo.toml`: - RUSTSEC-2026-0007 bytes 1.9.0 -> 1.11.1+ (BytesMut::reserve overflow) - RUSTSEC-2025-0024 crossbeam-channel 0.5.14 -> 0.5.15+ (double free on Drop) - RUSTSEC-2025-0009 ring 0.17.8 -> 0.17.12+ (AES panic under overflow checks) - RUSTSEC-2026-0009 time 0.3.37 -> later (DoS via stack exhaustion) Plus RUSTSEC-2026-0012 keccak 0.1.5 (unsoundness + yanked) via transitive bump. `cargo audit` now reports 0 vulnerabilities. - Remaining audit warnings (8 total) are all dev-only or upstream- rename notices (`criterion` pulling `atty`/`serde_cbor`, `swc` pulling `lru`/`rand 0.8.5`, `pqcrypto-dilithium` -> replaced by `pqcrypto-mldsa`, `rustls-pemfile` unmaintained) -- none reachable via an attacker-controllable path in production code. The pqcrypto-dilithium crate rename is the one meaningful follow-up and is deferred to its own commit.

170 days agor1870400018:10090replica 1870400018

written by h00gs <hello@oxedize.com>

7 operations, since the previous mark · compare with the head
+fe2o3_steel: mail server, wallet v2 admin users, session + webhooks, API proxy.

This commit covers two sessions' worth of uncommitted work. Broadly: 1. An in-process mail server (SMTP receive, SMTP submission, IMAP, DKIM signing, Maildir storage) added to Steel, replacing a postfix/dovecot/opendkim deployment. 2. A multi-admin wrapped-key wallet design in `fe2o3_crypto`, retiring the single-passphrase + `STEEL_WALLET_PASS` path. The running daemon now reads its passphrase from stdin at every cold start, with no disk-resident secret that can defeat the wallet's own at-rest threat model. tmux-managed start-up via a new `scripts/steel_ctl` helper. 3. Session-scoped + user-scoped WebSocket commands backed by the per-vhost Ozone database, with authentication primitives (register/login/logout/whoami) using Argon2id. 4. Outbound API proxy routes for browser POSTs forwarded to upstream HTTPS endpoints with server-side header injection. 5. Incoming webhook routes with a trait-based registration API so app crates can plug in their own handlers. 6. Plaintext HTTP redirect listener on a configurable port, and optional HSTS header injection into every HTTPS response. 7. Generic `{file:path}` and `{env:VAR:default}` placeholder substitution for config values. 8. Test and documentation scrubs to remove downstream app-specific names from `fe2o3_net` and `fe2o3_text` so the library stays vendor-neutral. Mail server ----------- New modules in fe2o3_net: - `smtp/server.rs` RFC 5321 + RFC 3207 + RFC 4954 session state machine over a `MaybeTls` enum stream, so a single state machine handles plain and STARTTLS-upgraded sockets without duplication. AUTH PLAIN / LOGIN on submission (587) after STARTTLS; receive path (25) refuses AUTH and gates `RCPT TO` on a `rcpt_acceptable` call into the handler. Accepts ESMTP extension parameters (`SIZE`, `BODY`, `ORCPT`, `NOTIFY`, etc.) by stripping them after the angle-bracketed address. Accepts address- literal `HELO [1.2.3.4]` / `EHLO [IPv6:...]` per RFC 5321 §4.1.3 rather than FQDN-only. DATA phase reads until `<CRLF>.<CRLF>` with dot-unstuffing, 20 MB message cap, 100 rcpt cap, 4 KB line cap. - `smtp/client.rs` Outbound SMTP client. Resolves the recipient domain's MX via the in-tree DNS resolver, connects to each exchange in preference order, does opportunistic STARTTLS against the system CA trust store, walks MAIL / RCPT / DATA, dot-stuffed body, one retry on transport errors. Uses `tokio_rustls::client::TlsStream` via a dedicated `ClientStream` enum (the server- side `MaybeTls` holds the server-side `TlsStream` type, which is distinct). - `smtp/handler.rs` `SmtpHandler` trait: `deliver_inbound`, `submit_outbound`, `rcpt_acceptable`, plus `SmtpTransaction` and `HandlerOutcome` types. - `smtp/cmd.rs` Typed SMTP command parser. Rewritten to accept ESMTP extension parameters after `MAIL FROM:<addr>` and `RCPT TO:<addr>`, and to accept any non-empty token as the HELO / EHLO argument (no FQDN validation). - `imap/server.rs` IMAP4rev1 session loop, Thunderbird subset: CAPABILITY, LOGIN, NOOP, LOGOUT, LIST, LSUB, SUBSCRIBE, UNSUBSCRIBE, CREATE, DELETE, SELECT, EXAMINE, CLOSE, STATUS, FETCH and `UID FETCH` (with UID, FLAGS, RFC822.SIZE, INTERNALDATE, ENVELOPE, BODY[], BODY.PEEK[], BODY[HEADER.FIELDS ...], RFC822, RFC822.HEADER, RFC822.TEXT), STORE / `UID STORE` (+FLAGS, -FLAGS, FLAGS, SILENT), `UID SEARCH` (ALL, UID ranges, seen/unseen/ flagged/deleted), APPEND with synchronising and non-synchronising literals, EXPUNGE. Implicit TLS on 993. FETCH responses are streamed segment-by-segment so literal byte payloads reach the wire verbatim without passing through a `String` intermediate. Every FETCH response to a UID command implicitly includes the UID per RFC 3501 §6.4.8. LIST responses carry RFC 6154 SPECIAL-USE attributes (`\Sent`, `\Drafts`, `\Trash`, `\Junk`, `\Archive`) so clients auto-discover the standard folders. CAPABILITY advertises `SPECIAL-USE`. - `dkim.rs` DKIM signer (RFC 6376 + RFC 8463) using ed25519-sha256 with relaxed/relaxed canonicalisation. Uses ring's Ed25519KeyPair so key generation is in-tree (ring does not offer RSA key generation, which would have required a third-party crate). Signer holds its PKCS#8 bytes for persistence, exposes the public DNS TXT record value for publication. - `dns_resolver.rs` Minimal DNS-over-UDP client: A and MX queries, compression pointer following, single retry, no caching. Parses `/etc/resolv.conf` for the local nameserver. Roughly 250 lines so we do not need `hickory-resolver`. - `mail/store.rs` `MailStore` trait: `ensure_user`, `append`, `list_folders`, `folder_status`, `list_messages`, `fetch_bytes`, `set_flags`, `expunge`, `create_folder`, `subscribe`, `list_subscribed`. Plus `FolderName`, `MessageUid`, `MessageFlags`, `MessageMeta`, `FolderStatus`, `MailUser` types. - `mail/user.rs` `UserStore` trait: `authenticate`, `lookup`. New crate fe2o3_mail provides the on-disk implementations: - `fe2o3_mail/src/maildir.rs` `MaildirStore`, Maildir++ on disk. Preserves an existing `dovecot-uidlist` and `dovecot-uidvalidity` so a cut-over from dovecot keeps IMAP UIDs stable. `ensure_user` pre-creates `Sent`, `Drafts`, `Trash`, `Junk`, `Archive` so clients find them via SPECIAL-USE on first connect. Parses the dovecot subscription file header (`V\t2`) without misreading it as a folder name. - `fe2o3_mail/src/passwd.rs` `PasswdFileUserStore` reads a JDAT file of Argon2id-hashed mail user passwords via `fe2o3_hash::kdf::KeyDerivationScheme`. Reloaded on every authenticate call so password edits do not require a daemon restart. - `fe2o3_mail/src/outbound.rs` `OutboundSpool` is the on-disk queue for messages awaiting SMTP outbound delivery. Simple append- only text envelope + raw body, drained by a background worker. - `fe2o3_mail/src/lib.rs` `EmailAddress`, `EmailMessage`, `EmailSender` primitives. `NullEmailSender` for tests. Steel integration: - `fe2o3_steel/src/srv/mail.rs` Listener spawners for 25 / 587 / 993, the shared `TlsAcceptor` wiring, and the `build_smtp_servers` helper that parameterises the receive and submission modes off a single `AppMailHandler`. - `fe2o3_steel/src/app/mail.rs` `AppMailHandler` glue: concrete `MaildirStore` + `PasswdFileUserStore` + `OutboundSpool` + `DkimSigner` tuple, with a short-circuit local-delivery path for recipients in the configured `local_domains` list (writes straight to the mailbox via `MailStore::append`, avoiding a self-loop through the public MX) and an outbound spool drainer that retries on a 30 s cycle. - `MailConfig` added to `ServerConfig` (`fe2o3_steel/src/srv/cfg.rs`). Empty `mail` map disables. Fields: `hostname`, `smtp_port`, `submission_port`, `imap_port`, `maildir_root`, `users_file_rel`, `spool_dir_rel`, `dkim_key_file`, `dkim_selector`, `dkim_domain`, `local_domains`. - `steel mailpass -a ADDR -d DIR` shell subcommand prints a JDAT user entry with a fresh Argon2id hash, ready to paste into the mail user file. Reads `STEEL_MAIL_PASS` env var when stdin is not a tty. DKIM keys are generated in-process on first start if none exist at the configured path; the public TXT record is logged at each start-up. Local delivery via the short-circuit path avoids the "self-loop through the public MX fails because of a cert SAN mismatch" problem we hit on the first live cut-over. Wallet v2: multi-admin wrapped keys ----------------------------------- `fe2o3_crypto::keys::Wallet` was rewritten. The old generic `Wallet<const PH: usize, D>` with `passhashes` + `app_hashes` is gone (per the no-backwards-compat rule). The new shape: - `Wallet` holds `metadata`, a `Vec<AdminUser>` and an `enc_secs` map (for future encrypted app secrets). - `AdminUser` binds a name, a scope list, an expires_at, and a `WrappedKey`. - `WrappedKey` carries the KDF scheme name, the encoded KDF config (salt + Argon2id parameters, no hash), the encryption scheme name and the base2x-encoded AES-256-GCM ciphertext of the 32-byte wallet master key. - `Wallet::unlock(password)` iterates the admin list, tries to decrypt each wrap, returns `UnlockedWallet` (master key + matched admin name and scopes) on first success. Expiry is checked after the decrypt succeeds so the operator gets a clear "expired" error instead of a generic "wrong password". - `Wallet::create_with_first_admin` generates a fresh random master key and enrols one admin in a single step, used when a host application is creating a brand-new wallet. - `Wallet::enrol` and `Wallet::remove_by_name` are the authenticated-caller variants used after a successful startup `unlock`: they skip re-authentication and trust the caller to have already proven authority via an earlier unlock. `Wallet::add_admin` / `remove_admin` are the unauthenticated- caller variants that re-verify a supplied password. - `wrap_master_key` / `unwrap_master_key` are the low-level primitives, exposed for the one-shot migration path. This borrows the pattern LUKS uses for its key slots, PGP uses for multi-recipient encryption, and `age` uses for multi-recipient files. An operator can add or revoke an admin entry without disturbing the others, and any one valid password unlocks the wallet. `fe2o3_crypto` gained new dependencies on `fe2o3_hash`, `fe2o3_iop_hash` and `fe2o3_text` to keep the wrap / unwrap primitives in-crate. No cycle introduced because none of those depend back on fe2o3_crypto. fe2o3_steel integration: - `AppShellContext` caches the unlocked admin's name and scope list alongside the master key, so subsequent privileged subcommands in the same invocation do not re-prompt the caller for a password. - `tui.rs` unlocks the wallet at start-up via stdin prompt (`UserInput::ask_for_secret`), with a `STEEL_ADMIN_PASS` env var bypass for development / scripted tests. There is deliberately no disk-resident passphrase fallback -- a wallet that can be unlocked with a secret stored on the same disk it protects provides no real defence against the threat model it was built for. - A bootstrap bypass path detects `wallet --migrate` before the normal unlock runs and dispatches to a one-shot migration function (`migrate_legacy_wallet_inline` in `tui.rs`) which reads the legacy wallet as raw `Dat`, verifies the current passphrase against the legacy `wallet_pass_hashes`, derives the current database encryption key (becomes the new master key unchanged, so no Ozone re-encryption is required), writes a new v2 wallet with one admin entry wrapping the same master key, and backs up the old wallet as `wallet.jdat.pre-admins`. - New admin subcommands under a `Wallet` category: wallet --migrate one-shot legacy migration admin --list name/expires/scopes table admin --add NAME enrol new admin, caller must hold the "admin" scope or "*" admin --remove NAME remove an admin, same scope gate, refuses the last remaining admin - Every privileged invocation appends a line to an append-only `admin-audit.log` next to the wallet: `<unix_seconds> <caller> <verb> <result> <detail>`. Failures to write the audit log are warnings, not fatal, so the CLI keeps working even if the log file is temporarily unavailable. - `fe2o3_shield` mirrors the same unlock flow. Operator: tmux-managed start-up ------------------------------- The previous `STEEL_WALLET_PASS` via `EnvironmentFile` systemd path is gone. Steel now reads its passphrase from stdin on every cold start. The intended operator flow is to run Steel inside a long- lived tmux session so SSH sessions can detach while the process keeps running. A new helper wraps the tmux ceremony: fe2o3_steel/scripts/steel_ctl {start|stop|restart|status|attach|logs} `start` attaches a fresh tmux session running `./steel server`, the operator types the passphrase on stdin, detaches with Ctrl-b d once the listeners bind. `status` reports the tmux session and the bound ports. The other verbs are self-explanatory. The five pre-existing deploy helper scripts (`steel_attach`, `steel_deploy`, `steel_deploy_prod`, `steel_logs`, `steel_probe`) have been removed from the in-tree scripts directory; their functionality has been folded into per-app scripts maintained by each downstream deployment. WebSocket session model + auth primitives ----------------------------------------- `fe2o3_steel/src/srv/ws/handler.rs` grew a full session-aware command surface. Every WebSocket connection that arrives with (or is issued) a session cookie carries that session id through to the handler, where it is used to namespace session-scoped storage: - `sess_put "key" "value"` write to session-scoped Ozone key - `sess_get "key"` read from session-scoped Ozone key - `sess_all` list all keys under this session The same mechanism, gated on successful authentication, exposes user-scoped commands: - `register "user" "pass"` register a new user (Argon2id) - `login "user" "pass"` authenticate, bind the user id to the current session - `logout` clear the bound user from the session - `whoami` return the currently bound user id - `user_put "key" "value"` write to user-scoped Ozone key - `user_get "key"` read from user-scoped Ozone key Keys are server-side prefixed so browser code cannot cross namespaces. The authentication primitives store per-user password hashes via `fe2o3_hash::kdf::KeyDerivationScheme`. Session cookies are `HttpOnly`, `Secure`, `SameSite=Lax` by default, driven by the new `allow_anonymous_sessions` flag in `ServerConfig`. `WebSocketHandler::attach_sid` lets the handler receive a cloned copy with the session id attached just before the upgrade, so per-request state can be captured without borrowing across the upgrade boundary. API proxy routes + webhook handlers ----------------------------------- Per-vhost outbound API proxy routes map a local POST path to an upstream HTTPS endpoint, with server-side header injection: api_routes: [ { "path": "/api/example/checkout", "upstream": "https://api.example.com/v1/checkout", "headers": { "Authorization": "Bearer {file:./keys/sk}" } } ] Headers are parsed once at config load time and `{file:path}` / `{env:VAR:default}` placeholders are resolved to actual strings, so secrets stay out of the config file. Upstream HTTPS uses a rustls client built with the system CA trust store. Per-vhost incoming webhook routes map a local POST path to a named handler, with handler-specific config: webhook_routes: [ { "path": "/webhook/example", "handler": "example_handler", "config": { "api_key": "{file:./keys/api}" } } ] Handlers are registered by name at start-up via the new `WebhookRegistry` / `WebhookHandler` trait: let mut reg = WebhookRegistry::new(); reg.register("example_handler", MyHandler); run_with_webhooks(reg)?; The registry dispatches POSTs by name with a single hash lookup and no trait-object overhead. App crates can ship a thin `main.rs` that registers their handlers and hands off to `run_with_webhooks`. A public `prelude` module in `fe2o3_steel` re-exports the webhook trait, registry, route type, and the utility functions handlers typically need (`url_encode`, `extract_value`, `extract_json_string`). Plaintext HTTP redirect + HSTS ------------------------------ `ServerConfig::server_port_tcp_plaintext` (defaulting to 0 = off) binds a separate port for plaintext HTTP traffic and answers every incoming request with a 301 to the HTTPS equivalent, preserving `Host`, path and query. The same handler also catches plaintext traffic that accidentally lands on the TLS port. Typical production setting is 80. `ServerConfig::hsts_max_age_secs` (default 0 = off) injects a `Strict-Transport-Security` header into every HTTPS response. Config placeholders ------------------- `{file:path}` and `{env:VAR:default}` substitution is applied to string values in the config at load time, so secrets can live in mode-0600 files or environment variables rather than directly in `config.jdat`. Used by API routes, webhook routes and anywhere else a vhost needs a credential that should not be checked in. Vendor-neutral test and doc scrubs ---------------------------------- `fe2o3_net/src/acme/rfc8555.rs` tests previously used hard-coded downstream hostnames. They now use `example.com` / `www.example.com`. `fe2o3_text/src/table.rs` example comments likewise scrubbed. This keeps fe2o3 free of downstream app-specific names in line with the project's vendor-neutral rule. `fe2o3_net/src/acme/*` also gained a handful of defensive adjustments: the vendored ACME challenge deserialisation tolerates missing `token` and `url` fields (the shape the CA actually sends in one code path), and the in-tree test coverage of that deserialisation was extended. Fixes along the way ------------------- - Thunderbird's `UID FETCH 1:* (FLAGS)` returned FLAGS without an implicit UID, so Thunderbird could not tie flags to server UIDs and silently dropped the result. RFC 3501 §6.4.8 requires the UID to be injected on every FETCH response to a UID command; `imap/server.rs` now does. - Dovecot's `dovecot-uidlist` header row `3 V0 N7` was being misparsed as a data row (because its first token happens to be a small integer), so UID 3 could not be located on disk. `fe2o3_mail::maildir::parse_uid_line` now rejects data lines whose second token does not begin with `:` or a digit. - Dovecot's `subscriptions` file has a `V\t2` header that the initial LSUB parser read as the folder name `V 2`, so Thunderbird showed a spurious "V 2" folder in the sidebar. Filtered. - Gmail's SMTP client sends `MAIL FROM:<addr> SIZE=...` and `EHLO [w.x.y.z]`. The initial parser accepted neither, causing the first inbound from Gmail to bounce with 500. The command parser now strips ESMTP extension parameters and accepts address-literal HELO / EHLO. - The IMAP command tracer originally logged the entire `LOGIN` command line at info level, which landed the IMAP password in the systemd journal in plaintext for one iteration cycle. Now redacts the second argument of `LOGIN`. (The journal in question was also rotated and vacuumed after the fact.) - The IMAP FETCH response was originally built as a `parts.join(" ")` string, which meant the raw byte payload of `BODY[]` / `RFC822` literals passed through a lossy `String::from_utf8_lossy` on its way to the wire. It is now streamed segment-by-segment so literal byte payloads reach the wire verbatim with the byte counts on the literal markers matching. - The SMTP submission state machine originally refused `AUTH PLAIN <inline-base64>` because the old parser counted arguments too strictly and treated the third token as an unknown command. Now accepts the inline initial response. Test coverage ------------- - `fe2o3_crypto::keys::tests` covers the wrap / unwrap roundtrip, the wallet create / unlock roundtrip through a full JDAT serialise, and the add / remove admin flow including the "bob (restart scope) cannot add a new admin" and "alice can remove bob, cannot remove the last admin" cases. - `fe2o3_net/tests/smtp.rs` and `fe2o3_net/tests/email.rs` still pass with the expanded parser. - `fe2o3_steel/tests/server.rs` adjusted for the new ServerConfig shape. Documentation ------------- `doc/Hematite/sec_steel.typ` grew new sections on the mail server and the multi-admin wallet, plus the tmux-managed start-up model. The existing "What Is Working" list has been updated and the stale "SMTPS is planned" line removed. File summary ------------ New files: fe2o3_mail/ (entire crate) Cargo.toml src/lib.rs src/maildir.rs src/outbound.rs src/passwd.rs fe2o3_net/src/dkim.rs fe2o3_net/src/dns_resolver.rs fe2o3_net/src/imap/mod.rs fe2o3_net/src/imap/server.rs fe2o3_net/src/mail/mod.rs fe2o3_net/src/mail/store.rs fe2o3_net/src/mail/user.rs fe2o3_net/src/smtp/client.rs fe2o3_net/src/smtp/server.rs fe2o3_steel/src/app/mail.rs fe2o3_steel/src/srv/http.rs fe2o3_steel/src/srv/mail.rs fe2o3_steel/src/srv/webhook.rs fe2o3_steel/scripts/steel_ctl Removed: fe2o3_steel/scripts/steel_attach fe2o3_steel/scripts/steel_deploy fe2o3_steel/scripts/steel_deploy_prod fe2o3_steel/scripts/steel_logs fe2o3_steel/scripts/steel_probe Rewrote: fe2o3_crypto/src/keys.rs fe2o3_steel/src/app/tui.rs fe2o3_steel/src/app/repl.rs fe2o3_steel/src/app/syntax.rs fe2o3_steel/src/srv/server.rs fe2o3_steel/src/srv/cfg.rs fe2o3_steel/src/srv/https.rs fe2o3_steel/src/srv/ws/handler.rs Touched: Cargo.toml, Cargo.lock, .gitignore fe2o3_crypto/Cargo.toml fe2o3_net/src/lib.rs fe2o3_net/src/smtp/{cmd,handler,mod,msg}.rs fe2o3_net/src/acme/{challenge,client,rfc8555}.rs fe2o3_net/src/ws/handler.rs fe2o3_net/tests/{email,smtp}.rs fe2o3_shield/src/app/{tui,repl}.rs fe2o3_steel/Cargo.toml fe2o3_steel/src/app/{constant,https,mod,server,smtps}.rs fe2o3_steel/src/lib.rs fe2o3_steel/src/srv/{context,mod,ws/syntax}.rs fe2o3_steel/tests/{client,server}.rs fe2o3_text/src/table.rs

170 days agor1870400018:10082replica 1870400018

written by h00gs <hello@oxedize.com>

285 operations, since the previous mark · compare with the head
+fe2o3_steel: multi-vhost, ACME, headless / systemd prod deployment.

Note: AI coding assistance is being used increasingly in this project. This commit is one example of that collaboration: the design decisions, review and production cutover were driven interactively, and the resulting code was written with assistance from an AI coding agent. Replaces Caddy for example.com and app.example.com on deployhost. New server features (all in fe2o3_steel/src/srv/): - Multi-vhost config via `ServerConfig::vhosts`: a list of VhostConfig entries, each with its own hostnames, webroot, static routes, default index files and redirect rules. Old singular fields removed. - SNI-based certificate dispatch: `SteelCertResolver` implements `rustls::server::ResolvesServerCert`, keying `Arc<CertifiedKey>` by hostname and falling back to the first vhost's cert when SNI is absent. - Built-in ACME (Let's Encrypt) client via `rustls-acme` using TLS-ALPN-01. `AcmeConfig` toggles it on, defaulting to the LE staging endpoint. The accept loop spawns a background state pump that drives issuance and renewal. `acme status` / `acme renew` shell commands. - Per-vhost redirect rules (`RedirectRule` with Exact / Prefix / All match kinds, `{uri}` placeholder substitution, 301/302/307/308 status codes). - Host header validation: mismatched SNI vs Host returns `421 Misdirected Request`. - `server_address` and `server_port_tcp` now actually honoured (was hardcoded to 0.0.0.0:8443). - Wallet passphrase falls back to the `STEEL_WALLET_PASS` env var when set, so systemd services with `EnvironmentFile` can start Steel headless without a tty prompt. Bug fixes along the way: - `rt.spawn(async move { watcher.watch() })` hogged Tokio's only worker on single-CPU hosts (deployhost) and silently starved the accept loop. Switched to `rt.spawn_blocking`. - `with_single_cert` replaced with `with_cert_resolver` so SNI dispatch works at all. - ALPN advertises `http/1.1` and `acme-tls/1`. We deliberately do NOT advertise `h2` because Steel's HTTP parser is HTTP/1.1 only; advertising it made HTTP/2-capable clients send the `PRI * HTTP/2.0` preface, which we cannot parse. - Removed `Certificate::new_lets_encrypt` (three platform-specific certbot shellouts) and its `cert -p` shell command, all superseded by the in-process ACME client. TLS stack upgrade: - `rustls 0.22 -> 0.23` (with `features = ["ring"]`), `rustls-acme 0.9 -> 0.15.1`, `tokio-rustls 0.25 -> 0.26`. - `fe2o3_steel/src/main.rs`: installs the process-wide default crypto provider via `rustls::crypto::ring::default_provider().install_default()` at the top of `fn main()`, which rustls 0.23 now requires before any `ServerConfig::builder()` call. All other rustls / rustls-acme call sites in `srv/cert.rs` compile unchanged. - `fe2o3_net/Cargo.toml`: bumps its (unused) `tokio-rustls` dep from 0.25 to 0.26 so the final binary no longer links two rustls versions. Eliminate the `rustls-acme` dependency entirely: - The Cargo.toml `[patch.crates-io]` stanza that redirected `rustls-acme` to a vendored fork under `_vendor/rustls-acme-0.15.1/` is gone. The fork is deleted. `rustls-acme`, `futures-rustls`, `async-web-client` and `webpki-roots` are all gone from the workspace lockfile. Steel no longer imports `rustls_acme`, and its `Cargo.toml` no longer lists it as a dep. - Replacement: a new in-tree ACME client under `fe2o3_net/src/acme/` that implements the subset of RFC 8555 Steel actually needs -- directory discovery, account registration, order, authorisation, TLS-ALPN-01 challenge, finalisation, certificate download -- plus an async HTTPS client primitive under `fe2o3_net/src/http/client.rs` and a rewrite of `fe2o3_steel/src/srv/cert.rs` to drive issuance and renewal through it. Rationale matches Oxedyne's existing "minimal external dependencies" and "extract generic code into the appropriate Hematite package" principles: ACME is a network protocol and belongs next to http/, smtp/, ws/, email/ and dns.rs rather than in a third-party crate with its own transitive tree. New in-tree ACME client in fe2o3_net::acme: - Seven new modules under `fe2o3_net/src/acme/`, plus one extracted HTTPS client helper alongside them: * jose.rs -- ES256 JSON Web Signature primitive. Generates or loads a PKCS#8 account key via `ring`, computes the RFC 7638 JWK thumbprint, and produces flattened-JSON JWS objects shaped as `Dat::Map`. 380 lines. * rfc8555.rs -- Typed request/response structs for the ACME wire format (`Directory`, `Account`, `Order`, `Authorization`, `Challenge`, `Problem`) built on the existing `FromDatMap` derive. The `Challenge::{url, token}` fields are marked `#[optional]`, which is the direct equivalent of the vendored `#[serde(default)]`s that the rustls-acme fork was originally carrying; verified by a regression test that parses an authorisation containing a tokenless challenge. Also provides request builders (`new_account_request`, `new_order_request`, `finalize_request`) and typed accessors. 420 lines. * challenge.rs -- Builds self-signed tls-alpn-01 challenge certificates with the RFC 8737 id-pe-acmeIdentifier extension via `rcgen::CustomExtension::new_acme_identifier`. Returns raw `(cert_der, key_der)` bytes without touching rustls types, so the module remains unit-testable with nothing but rcgen and ring. 180 lines. * trust.rs -- Pinned Let's Encrypt root anchors (ISRG Root X1 RSA-4096, ISRG Root X2 ECDSA-P384) compiled in as base64 string constants extracted from webpki-roots and decoded at startup. Avoids a `webpki-roots` dependency entirely. 200 lines. * cache.rs -- Disk cache for the ACME account key (PKCS#8) and the issued cert+key, with atomic write-then-rename semantics. 300 lines. * client.rs -- `AcmeClient` state machine. Drives directory discovery, nonce fetch, account registration, new-order, authz fetch, challenge installation via a caller-supplied `ChallengeInstaller` trait, challenge ready signalling, order/authz polling, CSR build and finalisation, and certificate download. Transparent `badNonce` retry. URL splitting, Location/Replay-Nonce header extraction, and CSR generation are local helpers. Sets the CSR distinguished-name CommonName to the first requested DNS name -- rcgen's default CN of "rcgen self signed cert" is an invalid domain and Let's Encrypt rejects a finalise POST with that CN as `rejectedIdentifier`. 900 lines. * http/client.rs -- General-purpose async HTTPS client extracted from the pattern in `fe2o3_steel/tests/client.rs`: `TcpStream::connect` -> `TlsConnector::from(Arc<ClientConfig>)` -> write formatted request -> read response via `HttpMessage::read`. Used by the ACME client above and available to any other outbound HTTPS caller in fe2o3_net. No HTTP/2, no chunked transfer encoding, one request per connection. 350 lines. - Dependencies added to fe2o3_net: `ring = "0.17"` and `rcgen = "0.12.0"` as direct deps. Both were already in the workspace transitively via fe2o3_steel, so no new workspace-level deps are introduced. No `serde`, no `serde_json`, no `webpki-roots`, no `async-web-client`. Steel integration (`fe2o3_steel/src/srv/cert.rs`): - `SteelCertResolver` is rewritten to put its `by_hostname` map, its `default_cert` pointer, and a new `challenge_certs` map behind `RwLock`s so the ACME renewer running in a background task can install challenge certs and swap issued certs under a live resolver without stopping the accept loop. - The resolver's `resolve()` method now detects ACME challenge handshakes by checking whether the ClientHello's ALPN offer is exactly `{"acme-tls/1"}` -- the same check rustls-acme's internal resolver uses -- and routes those connections to the challenge cert map keyed by SNI. Regular handshakes go through the SNI lookup then default cert fallback as before. - `SteelCertResolver` implements `fe2o3_net::acme::ChallengeInstaller` so the `AcmeClient` can call `install` and `remove` on it directly via an `Arc<SteelCertResolver>` shared with the accept loop. - New `AcmeRenewer` type that owns the `AcmeClient`, the `AcmeDiskCache` and an `Arc` to the shared resolver. Its `run_forever()` method attempts the initial issuance if no cached cert exists (or the cached cert is older than the 60-day renewal threshold), then loops with a 24-hour tick re-checking the threshold and re-issuing whenever needed. Result is stored in the disk cache atomically and swapped into the live resolver on each success. - `load_acme()` is rebuilt to wire this up: construct the ACME client with the pinned LE trust config, load any existing cached cert into the resolver immediately, return both the `ServerConfig` and the `AcmeRenewer` for `server.rs` to spawn. `server.rs` spawns a tokio task that calls `renewer.run_forever()`. - `rustls-acme` and its transitive `futures-rustls`, `async-web-client` and `webpki-roots` (the latter was a dead dev-dep in Steel's tests/client.rs referencing it only in a commented-out line) are removed from Steel's dependency tree. Mid-session bug fixes uncovered by end-to-end staging runs: - `fe2o3_jdat/src/string/enc.rs:633` -- `Dat::Bool(_)` was encoded as `fmt!("\"{}\"", typ_str)`, i.e. the kind name (`"true"` or `"false"`) wrapped in quotes. In JSON mode that produced the JSON *string* `"true"` where a JSON *literal* `true` was required. Let's Encrypt's JSON parser correctly rejected `new_account` payloads containing such a field with a 400 response, `urn:ietf:params:acme:error:malformed`, detail "Error unmarshaling JSON". Fixed by emitting the bare `true` / `false` token as the value string; the `is_dataless = true` flag still drives the jdat-native `({typ_str})` kindicle wrapping at line 969, so jdat-native output of booleans is unchanged. A regression test `test_new_account_request_json_bool_round_trips` in `fe2o3_net::acme::rfc8555` asserts that `new_account_request`'s `.json()` output parses back via `parse_json_response` with the boolean field round-tripping as `Dat::Bool(true)`. - `fe2o3_net/src/media.rs` -- the `Application` media subtype enum refused unknown subtypes with `"Unrecognised Application Media subtype ..."`, which made the HTTP message parser reject every response Content-Type it didn't know about, including Let's Encrypt's error responses which use `application/problem+json` (RFC 7807). Added a catch-all `Application::Other(String)` variant so the parser constructs an `HttpMessage` for any Content-Type and the caller can still read the body. The `MediaType::is_text()` classifier now also recognises any `+json` or `+xml` suffixed subtype via the RFC 6838 §4.2.8 structured-syntax-suffix convention, so structured JSON bodies like `problem+json` and `jose+json` log as text rather than hex. - `fe2o3_net/src/acme/cache.rs` -- `AcmeDiskCache::certificate_path` and `account_key_path` accessors added so Steel's `AcmeRenewer` can stat the cache's cert file (to check mtime against the renewal threshold) without hard-coding the filename. Testing: - 44 unit tests across the new `fe2o3_net::acme` and `fe2o3_net::http::client` modules, all green. Includes the tokenless-challenge regression test, the jdat-bool JSON round-trip regression test, end-to-end JWS sign-and-verify via `ring`, and CSR generation verification. - **Live staging cycle** against `acme-staging-v02.api.letsencrypt.org` on deployhost: full issuance (account registration, new-order, authorisation, tls-alpn-01 challenge installation, challenge validation, order finalisation, certificate download, resolver swap, disk cache persistence) completed in ~14 seconds for two DNS names with zero errors. Uncovered and drove the two mid-session fixes above. - **Live production cutover** against `acme-v02.api.letsencrypt.org` on deployhost: the new binary has replaced the vendored-rustls-acme binary in `/home/jason/usr/steel-prod/steel` and is the active `steel.service`. Initial issuance (four DNS names: `example.com`, `www.example.com`, `app.example.com`, `www.app.example.com`) completed in ~7 seconds. HTTPS probes to `example.com` and `app.example.com` return 200 in ~45 ms, served under a fresh `Let's Encrypt / E7`-issued cert valid through July 2026. Previous binary is kept as `/home/jason/usr/steel-prod/steel.rustls-acme.prev` for rollback. Operator scripts (fe2o3_steel/scripts/, negated in .gitignore): - steel_deploy dev loop: build + rsync + tmux-hosted restart on ~/usr/steel-dev, port 8443, self-signed cert. - steel_deploy_prod prod cutover: build + rsync + setcap + systemctl restart on ~/usr/steel-prod, port 443, real LE cert, ends with a four-vhost smoke probe. - steel_probe runs the full smoke matrix (SNI dispatch, redirect rules, Host mismatch) via ssh. - steel_logs tails journal / raw stdout / tmux pane. - steel_attach ssh -t into the dev tmux session. Tests (fe2o3_steel/tests/): - main.rs, client.rs: pre-existing breakage, fixed minimally (`FileConfig::new` argument, `Path::new(&home)`). - server.rs: rewritten to construct a `VhostRuntime` + `HashMap` + new `Protocol::Web { vhosts, default_vhost, dev_mode }` shape. Treated as a compile-time regression guard; end-to-end is now verified on deployhost.

172 days agor1870400018:9796replica 1870400018

written by h00gs <hello@oxedize.com>

430 operations, since the previous mark · compare with the head
+Update README.md with Oxedyne logo.172 days agor1870400018:9365replica 1870400018

written by h00gs <hello@oxedize.com>

2 operations, since the previous mark · compare with the head
+Belatedly updating the logo follow the renaming from Oxedize to Oxedyne.172 days agor1870400018:9362replica 1870400018

written by h00gs <hello@oxedize.com>

4 operations, since the previous mark · compare with the head
+fe2o3_geom: - Added areas and perimeters of shapes.387 days agor1870400018:9357replica 1870400018

written by h00gs <hello@oxedize.com>

7 operations, since the previous mark · compare with the head
+fe2o3_social: - Fixed multi-profile graph generation.388 days agor1870400018:9349replica 1870400018

written by h00gs <hello@oxedize.com>

46 operations, since the previous mark · compare with the head
+fe2o3_social: - Grappling with a very large social network simulation and grasping for options, so implemented file IO alternative to memory mapping, and a persistent file handle.390 days agor1870400018:9302replica 1870400018

written by h00gs <hello@oxedize.com>

36 operations, since the previous mark · compare with the head
+fe2o3_core: - Expanded random sampling variants with one that takes explicit mean and stdev. fe2o3_social: - Some tweaks to social circle construction for an new ProfileType::Standard.391 days agor1870400018:9265replica 1870400018

written by h00gs <hello@oxedize.com>

40 operations, since the previous mark · compare with the head
+fe2o3_jdat: - Dat::Tup numeric variants properly fleshed out. fe2o3_syntax: - In fe2o3_syntax::msg, implemented Msg::coerce_to_expected_kind association function to coerce default kinds produced by string decoding in fe2o3_jdat to kinds specified in a Syntax.395 days agor1870400018:9224replica 1870400018

written by h00gs <hello@oxedize.com>

40 operations, since the previous mark · compare with the head
+fe2o3_core: - Move random sampling functionality from fe2o3_social::graph to fe2o3_core::rand. fe2o3_social: - Deleted fe2o3_social::graph::GeographicParams, no longer needed as responsibility for person data shifted to user. fe2o3_geom: - Slight expansion of functionality with fe2o3_geom::shape.398 days agor1870400018:9183replica 1870400018

written by h00gs <hello@oxedize.com>

47 operations, since the previous mark · compare with the head
+fe2o3_social: - Downgraded some types from usize to u32 and f64 to f32. fe2o3_jdat: - Created more flexible enum_getter_numeric! macro for automatic coercion of numeric values via FromDatMap.402 days agor1870400018:9135replica 1870400018

written by h00gs <hello@oxedize.com>

94 operations, since the previous mark · compare with the head
+fe2o3_social: - Cleaning up the AI slop produced by Claude Code. - Reduced scope of the crate to modelling large social graphs using memory mapping.405 days agor1870400018:9040replica 1870400018

written by h00gs <hello@oxedize.com>

65 operations, since the previous mark · compare with the head
+fe2o3_core: - Changed set_log_level! to accept not just a literal but an expression. - Created mem::get_memory_usage_mb. fe2o3_social: - Better reporting for social graph generation especially useful for large graphs. - Optimised Link to allow larger social graphs: - Implemented memory mapping for large graphs.409 days agor1870400018:8974replica 1870400018

written by h00gs <hello@oxedize.com>

11 operations, since the previous mark · compare with the head
+fe2o3_social: Created graph.rs using fe2o3_data::digraph.415 days agor1870400018:8962replica 1870400018

written by h00gs <hello@oxedize.com>

15 operations, since the previous mark · compare with the head
+fe2o3: Cleaned up some old oxedize -> oxedyne references. fe2o3_social: Created new crate, spun out from fe2o3_stds, as a library for social networks. fe2o3_data: Created directed graph structure DiGraph.417 days agor1870400018:8946replica 1870400018

written by h00gs <hello@oxedize.com>

117 operations, since the previous mark · compare with the head
+fe2o3_text: Added Tabular for pretty printing text tables with simple builder API.441 days agor1870400018:8828replica 1870400018

written by h00gs <hello@oxedize.com>

3 operations, since the previous mark · compare with the head
+fe2o3_text: some fixes to base2x tests.452 days agor1870400018:8824replica 1870400018

written by h00gs <hello@oxedize.com>

8 operations, since the previous mark · compare with the head
+Doc comment and TODO.md created for fe2o3_shield.456 days agor1870400018:8815replica 1870400018

written by h00gs <hello@oxedize.com>

15 operations, since the previous mark · compare with the head
+Fixed all fe2o3_datime compile warnings.460 days agor1870400018:8799replica 1870400018

written by h00gs <hello@oxedize.com>

96 operations, since the previous mark · compare with the head
+Test fixes associated with removal of unwrap() calls in fe2o3_datime.461 days agor1870400018:8702replica 1870400018

written by h00gs <hello@oxedize.com>

39 operations, since the previous mark · compare with the head
+Continuing with porting java calclock to fe2o3_datime.461 days agor1870400018:8662replica 1870400018

written by h00gs <hello@oxedize.com>

378 operations, since the previous mark · compare with the head
+- Renamed software house Oxedize to Oxedyne, so that Overlay users can speak of "oxedizing". Github organisation renamed Oxedize -> oxedyne-io (Oxedyne taken). - All code references to [Oo]xedize -> [Oo]xedyne.463 days agor1870400018:8283replica 1870400018

written by h00gs <hello@oxedize.com>

1788 operations, since the previous mark · compare with the head
+fe2o3_datime: New package! I supervised Claude Code to port an old java library of mine called CalClock over to Rust, with enhancements.

fe2o3_stds: - Added more regions to get_country_population_data.

464 days agor1870400018:6494replica 1870400018

written by h00gs <hello@oxedize.com>

219 operations, since the previous mark · compare with the head
+fe2o3_stds: - Added Country functionality to provide population related data. - Allows (very) approximate selection of a random location in any country weighted by population density.466 days agor1870400018:6274replica 1870400018

written by h00gs <hello@oxedize.com>

5 operations, since the previous mark · compare with the head
+fe2o3_num: Added with_commas and with_commas_dp and renamed with_sep_precision to with_sep_dp because the originals were too verbose for practical use.467 days agor1870400018:6268replica 1870400018

written by h00gs <hello@oxedize.com>

7 operations, since the previous mark · compare with the head
+Merge branch 'main' of github.com:Oxedize/fe2o3472 days agor1870400018:6260replica 1870400018

written by h00gs <hello@oxedize.com>

0 operations, since the previous mark · compare with the head
+fe2o3_steel: Using Steel to develop a web app, and feeding back improvements

- More flexible, better specification of app_root in config.jdat.

484 days agor1870400018:6259replica 1870400018

written by h00gs <hello@oxedize.com>

37 operations, since the previous mark · compare with the head
+Modified filename form for text bundles of files produced by txtcat.

fe2o3_stds: - Implemented fe2o3_core::new_enum functionality for Country allowing rand method. - Created culture::Gender enum with rand_minority method. - Applied the strum derive for automated Display impls for fe2oe_stds enums. fe2o3_core: - Removed dependency on fe2o3_stds by moving ANSI term chars into core. - Moved rand method into new_enum! and noted in comment reasons for choice of variant duplication in declaration. fe2o3_num: - Created fe2o3_num::string::ThousandsSeparator trait providing with_sep and with_sep_precision methods for all Rust number types.

472 days agor1870400018:6221replica 1870400018

written by h00gs <hello@oxedize.com>

23 operations, since the previous mark · compare with the head
+Track Cargo.lock for reproducible builds, necessary because Hematite contains some apps such as fe2o3_steel.486 days agor1870400018:6197replica 1870400018

written by h00gs <hello@oxedize.com>

2 operations, since the previous mark · compare with the head
+Modified filename form for text bundles of files produced by txtcat. (superseded by a later mark of this name)491 days agor1870400018:6194replica 1870400018

written by h00gs <hello@oxedize.com>

3 operations, since the previous mark · compare with the head
+fe2o3_steel: Returning after some time, aiming to finish Steel

- Logging now has multiple streams, a label is now given to the database log stream. - For a new app, the server now creates the tls/dev directory if it doesn't exist, in dev mode. - Various minor changes to be more accomodating to existing web app structures and contents, working towards being able to plonk the steel executable within the same directory as a www/ and serving should Just Work.

491 days agor1870400018:6190replica 1870400018

written by h00gs <hello@oxedize.com>

70 operations, since the previous mark · compare with the head
+fe2o3_o3db_sync: Incorporated sync log streaming.

- Removed capture of actual std::thread from Handle, wasn't being used and was problematic when using spawn_scoped (which needed to be abandoned anyway) - Propagating log_stream_id manually via each Bot - Seems to work in fe2o3_shield sim test

603 days agor1870400018:6119replica 1870400018

written by h00gs <hello@oxedize.com>

521 operations, since the previous mark · compare with the head
+fe2o3_shield, fe2o3_core: Multiple logging streams now working and shield sims test works as expected.

- Rename fe2o3_core::thread::SimplexThread to ThreadController.

607 days agor1870400018:5597replica 1870400018

written by h00gs <hello@oxedize.com>

123 operations, since the previous mark · compare with the head
+fe2o3_shield: Completed most of the code for the test sim.

- The server loop runs but sending the finish command doesn't seem to work.

610 days agor1870400018:5473replica 1870400018

written by h00gs <hello@oxedize.com>

69 operations, since the previous mark · compare with the head
+fe2o3_shield, fe2o3_core: Adjusted logging to use MultiStreamLoggerConsole for testing.613 days agor1870400018:5403replica 1870400018

written by h00gs <hello@oxedize.com>

14 operations, since the previous mark · compare with the head
+fe2o3_shield: Completed update of all log macro calls to include log_stream().613 days agor1870400018:5388replica 1870400018

written by h00gs <hello@oxedize.com>

119 operations, since the previous mark · compare with the head
+fe2o3_core, fe2o3_shield: Further work on multistream logging.

- Uses task-local storage for the log stream label.

613 days agor1870400018:5268replica 1870400018

written by h00gs <hello@oxedize.com>

92 operations, since the previous mark · compare with the head
+fe2o3_core: Created a PooledLoggerConsole and enhanced logging macros.

- In preparation for simulating multiple fe2o3_shield servers, to allow their individual log streams to be captured separately. - Requires an update to the logging calls in fe2o3_shield.

613 days agor1870400018:5175replica 1870400018

written by h00gs <hello@oxedize.com>

32 operations, since the previous mark · compare with the head
+fe2o3_shield: Make AppShellContext::start_server a standalone function.614 days agor1870400018:5142replica 1870400018

written by h00gs <hello@oxedize.com>

114 operations, since the previous mark · compare with the head
+fe2o3_shield: Differentiate three ProtocolModes, Production, Dev (activated via shell) and Test.

- My first use of matches! as alternative to PartialEq and ==

614 days agor1870400018:5027replica 1870400018

written by h00gs <hello@oxedize.com>

18 operations, since the previous mark · compare with the head
+fe2o3_shield: Inserted test_channel into Server to allow test commands to be sent in simulation tests.614 days agor1870400018:5008replica 1870400018

written by h00gs <hello@oxedize.com>

13 operations, since the previous mark · compare with the head
+fe2o3_o3db: Renamed fe2o3_o3db_sync in prep for fe2o3_o3db_async version.

- Updated to PROGRESS.md

615 days agor1870400018:4994replica 1870400018

written by h00gs <hello@oxedize.com>

133 operations, since the previous mark · compare with the head
+fe2o3_shield: Created ServerConfig::get_trusted_seeds in preparation for init msgs to seeds.

- Updated to new fe2o3_tui module structure with lib_tui.fe2o3_shield: Created ServerConfig::get_trusted_seeds in preparation for init msgs to seeds. - Updated to new fe2o3_tui module structure with lib_tui.

615 days agor1870400018:4860replica 1870400018

written by h00gs <hello@oxedize.com>

10 operations, since the previous mark · compare with the head
+fe2o3_tui ironic: Display a static file tree in the help window tab.616 days agor1870400018:4849replica 1870400018

written by h00gs <hello@oxedize.com>

17 operations, since the previous mark · compare with the head
+fe2o3_tui ironic: Minor tweaks.

- Removed call to clear_rect in FocalWindowRefresh match arm, superfluous and flickers coloured windows. - Edits to help window keys text.

616 days agor1870400018:4831replica 1870400018

written by h00gs <hello@oxedize.com>

11 operations, since the previous mark · compare with the head
+fe2o3_tui ironic: Added Menu Window command explanation to Help window Keys tab.616 days agor1870400018:4819replica 1870400018

written by h00gs <hello@oxedize.com>

17 operations, since the previous mark · compare with the head
+fe2o3_tui ironic bug fix: text panning right/left.616 days agor1870400018:4801replica 1870400018

written by h00gs <hello@oxedize.com>

2 operations, since the previous mark · compare with the head
+fe2o3_tui ironic: More selective rendering.

- No longer do full screen re-render for text box cursor movement that triggers no panning. - Less full screen rendering and more focal window only rendering.

617 days agor1870400018:4798replica 1870400018

written by h00gs <hello@oxedize.com>

79 operations, since the previous mark · compare with the head
+fe2o3_tui: Reorganised into app/lib structure and imported Ironic app code, which compiles and functions.625 days agor1870400018:4718replica 1870400018

written by h00gs <hello@oxedize.com>

89 operations, since the previous mark · compare with the head
+fe2o3_shield: Completed reorganisation, compiles and ready to use ShieldCommand::send to send initial HReq1 messages to bootstrap peers.626 days agor1870400018:4628replica 1870400018

written by h00gs <hello@oxedize.com>

195 operations, since the previous mark · compare with the head
+fe2o3_shield WIP: Mainly more reorganisation, introduction of associated type families.

- First use of associated type families like trait WireSchemeTypes to simplify generic parameter lists. - Reorganisation, moving more code into src/srv/msg with greater clarity on encode/decode of messages. - Objective is to complete and simplify sending of messages. - Include target address is pow pristine, more secure. - WIP: Does not compile. Current focus is src/srv/msg/encode.rs.

628 days agor1870400018:4432replica 1870400018

written by h00gs <hello@oxedize.com>

153 operations, since the previous mark · compare with the head
+fe2o3_shield: very minor removal of some comments.633 days agor1870400018:4278replica 1870400018

written by h00gs <hello@oxedize.com>

8 operations, since the previous mark · compare with the head
+Got shield shell and server running.

1. Go to fe2o3_shield/ 2. Run ./build (or ./build release) 3. Move newly created./shield executable to a new directory, say shield_test/ 4. In shield_test/, run ./shield 5. You can call the test app "Shield" with description "Test." 6. Yes to create a new wallet.jdat file, use a toy password like "toy" 7. type "help" at the shell command line to see what you can now do 8. type "server -t" to start the server 9. Its not yet functioning beyond this...

633 days agor1870400018:4269replica 1870400018

written by h00gs <hello@oxedize.com>

10 operations, since the previous mark · compare with the head
+Completed reorganisation of fe2o3_shield into app/srv structure.634 days agor1870400018:4258replica 1870400018

written by h00gs <hello@oxedize.com>

0 operations, since the previous mark · compare with the head
+Stop tracking .commit_msg.txt in shield_reorg branch.634 days agor1870400018:4257replica 1870400018

written by h00gs <hello@oxedize.com>

1 operation, since the previous mark · compare with the head
+Split fe2o3_shield to mimic fe2o3_steel app/srv main/lib structure.634 days agor1870400018:4255replica 1870400018

written by h00gs <hello@oxedize.com>

125 operations, since the previous mark · compare with the head
+Split fe2o3_shield/src into app and srv and copied fe2o3_steel files across.

- Now in process of merging/harmonising - Tokio removed - Steel is a single server with many short-lived interactions - Shield can use a single os Bot thread for listening and Bot threads for each connection

640 days agor1870400018:4129replica 1870400018

written by h00gs <hello@oxedize.com>

93 operations, since the previous mark · compare with the head
+Stop tracking .commit_msg.txt634 days agor1870400018:4035replica 1870400018

written by h00gs <hello@oxedize.com>

1 operation, since the previous mark · compare with the head
+Added .commit_msg.txt to .gitignore.639 days agor1870400018:4033replica 1870400018

written by h00gs <hello@oxedize.com>

2 operations, since the previous mark · compare with the head
+Renamed doc/ to dev/, added git cheatsheet, renamed commit to commit_all which now uses current branch.639 days agor1870400018:4030replica 1870400018

written by h00gs <hello@oxedize.com>

9 operations, since the previous mark · compare with the head
+fe2o3_net: fixed Fqdn validation, adding a dns test suite and verified correctness639 days agor1870400018:4020replica 1870400018

written by h00gs <hello@oxedize.com>

43 operations, since the previous mark · compare with the head
+Added Oxedize logo to README.641 days agor1870400018:3976replica 1870400018

written by h00gs <hello@oxedize.com>

4 operations, since the previous mark · compare with the head
+Mainly refactor of err!

README.md - Added logo. fe2o3_core: - Added line and file numbering directly into err! so err!(errmsg!(...)) now should be just err!(..). Refactored library. Landed on preferred formatting update of err! statements, a bit inconsistently applied at the moment, see fe2o3_o3db where it's generally implemented. fe2o3_steel: - "localhost" -> "localhost." for default domain name in server cfg.

641 days agor1870400018:3971replica 1870400018

written by h00gs <hello@oxedize.com>

2644 operations, since the previous mark · compare with the head
+- fe2o3: - Added .commit_msg.txt to commit script - fe2o3_steel: - Only show "you should update config.jdat..." msg for first time server cmd only if not in dev mode642 days agor1870400018:1326replica 1870400018

written by h00gs <hello@oxedize.com>

10 operations, since the previous mark · compare with the head
+Upgrade fe2o3_crypto from ed25519_dalek ver 1.0.1 to 2.0.0

- Prompted by GitHub security alert - Verified that all 'cargo test' tests working - Commented out Wallet serialisation test, not important at the moment

643 days agor1870400018:1315replica 1870400018

written by h00gs <hello@oxedize.com>

41 operations, since the previous mark · compare with the head
+Added git commit script644 days agor1870400018:1273replica 1870400018

written by h00gs <hello@oxedize.com>

3 operations, since the previous mark · compare with the head
+Documentation fixes

- Added Steel and Ironic to highlights of README.md - Moved fe2o3_steel README into README.md - Fixed name of Ironic as Iron Interactive Console

644 days agor1870400018:1269replica 1870400018

written by h00gs <hello@oxedize.com>

17 operations, since the previous mark · compare with the head
+Minor fixes to top level README.md644 days agor1870400018:1251replica 1870400018

written by h00gs <hello@oxedize.com>

4 operations, since the previous mark · compare with the head
+Start public tracking.645 days agor1870400018:1246replica 1870400018

written by h00gs <hello@oxedize.com>

1245 operations, from the start · compare with the head

Automatic marks

4 recovery points, one for each command that appended to the history. Ore writes these for itself, so each is a point `ore back` can reach, and each is named for the moment it was written rather than for anything a person meant to say.

NameWhenOperationAuthorHistory before it
+@2026-09-06T03:28:03.717079Z25 days agor1870400018:37466replica 187040001836 operations, since the previous mark · compare with the head
+@2026-09-05T18:06:32.468501Z25 days agor1870400018:36729replica 187040001876 operations, since the previous mark · compare with the head
+@2026-08-22T01:15:42.267522Z40 days agor1870400018:35388replica 187040001873 operations, since the previous mark · compare with the head
+@2026-08-21T16:47:08.854136Z40 days agor1870400018:35314replica 18704000187 operations, since the previous mark · compare with the head

+ signed and verified, ? signed by a key this forge does not carry, - unsigned

A mark imported from another version control system carries the identity the commit it came from was authored under, and where one does, that identity is shown beside the replica whose key signed the mark. The two are different facts: the signature attests that the replica wrote the mark, and the mark is what says who wrote the work.