SOW-0156: systemd v260.1 header rules and 4 GiB ceiling in both SDKs

KT
1d ago
+4073-326

Stacked on #17. Merge order: #13, #14, #15, #16, #17, then this. SOW-0156 (.agents/sow/done/SOW-0156-20261010-fixed-header-consistency-audit.md) is completed.

Decisions for review

  • Agent-chosen, pending your review:
    • Error names: Go ErrTimestampOutOfRange / ErrJournalFileFull, Rust JournalError::TimestampOutOfRange / JournalError::JournalFileFull (both Rust variants are unreleased and listed in docs/Rust-API.md).
    • A1, A4, A5 (Rust header gates at Go parity), approved 2026-11-03.
    • The effective file ceiling is 2^32 - 8 MiB, not 4 GiB: the arena grows in 8 MiB steps, so the last object must end there for the file to stay at or below 2^32 - 1.
  • Decided by you (2026-11-03): D1 = A, D2 = B, D3 = A (old large files stay supported), D4 = A, D5 = A, D6 = A, N1 = A (continuation is SOW-0171), N2 = A.
  • New, needs a decision later (not blocking): SOW-0172. The Rust Log accepts a relative directory, then fails on the first write; Go works. Options and a recommendation are in the SOW.

What changes

  • Append-open in both SDKs refuses, before mutation, what systemd v260.1 will not write: header size other than 272, unknown compatible flags, a missing tail-entry-boot-ID flag, archived or sealed files, invalid tail metadata, and files over 4 GiB. Log sets such an active file aside; root-retention Logs archive it.
  • Timestamps: writers refuse realtime outside 1..2^55-1 and monotonic at or above 2^55.
  • 4 GiB ceiling (chunk 2): both writers bound each entry's growth from payload sizes and return the file-full error when the entry could pass the ceiling. Nothing is written and the writer stays usable. Entry arrays stop doubling near the ceiling. Log rotates once and retries.
  • Readers: refuse unknown header states and short sealed headers. Rust accepts single-bucket hash tables. Both ignore the truncated 32-bit tail array hint above 4 GiB, so files earlier releases wrote stay readable.

Validation

  • go test ./... and cargo test --workspace pass, on this branch and on the top of the stack (#22).
  • Byte identity, compression, compact, live, verify and root-retention parity matrices pass. The journalctl query matrix passes against systemd v262.
  • Docs examples: 35/35 pass.
  • Fixtures written by the v0.10.0 writer (regular, compact, compressed, sealed, out-of-range timestamps, archived-at-active-name, 9.9 GB) are read by both SDKs and continued by Log.
  • Real use: a Log with no limits, fed 1 MiB entries.
    • Go: 22 files and 87,457 rows. Every full file is exactly 2^32 - 8 MiB.
    • Rust: rotates at the same point.
    • Seqnums are continuous in both runs. Go and Rust read each other's files, and both verifiers pass on all 24 files.
  • Benchmarks: interleaved pairs on a loaded host. Wall-clock results are inconclusive; paired user-CPU medians are within noise (Go -1.0% to +4.3%, Rust -8.1% to +3.9%). File size, mmap remap and map counts are identical in every pair.

Summary by cubic

Applies systemd v260.1's writable-open rules and timestamp bounds to both SDKs, bringing Rust header gates to Go parity, and records the SOW-0156 decisions that unblocked the work.

  • Both SDKs' append-open refuses header sizes other than 272, unknown compatible flags, a missing tail-entry-boot-ID flag, archived files, sealed files, invalid tail metadata, and regular files over 4 GiB, before any mutation. Log sets such an active file aside and continues the sequence; root-retention logs archive it under its archive name instead of failing to start. Continuing sealed files with the sealing key is tracked in SOW-0171.
  • Both SDKs' writers refuse realtime outside 1..2^55-1 and monotonic at or above 2^55 before mutation via new ErrTimestampOutOfRange / TimestampOutOfRange errors; Log no longer inherits out-of-range tails as clock floors.
  • Both SDKs' writers now refuse an entry that could grow the file past 4 GiB via new ErrJournalFileFull / JournalError::JournalFileFull errors, rotating once and retrying in directory writers, and returning the entry when it does not fit an empty file.
  • Both SDKs' opens reject unknown header states and short sealed headers before mutation; Log now sets such active files aside at startup and keeps the sequence instead of stopping, with Go's chain scan reading those headers for sequence identity. Rust accepts single-bucket hash tables, and both ignore the truncated 32-bit tail array hint above 4 GiB, keeping files from earlier releases readable.
  • Rejected opens and refused creates leave files byte-for-byte unchanged, proven by paired Go/Rust tests: both writers validate a new file's layout and sealing options before opening. Fixtures come from the v0.10.0 writer. The user confirmed the error names and parked the Rust relative-directory finding as SOW-0172.