step.11.txt -- b3ubot U2.2: emitter + THE round-trip oracle -- the structural inverse of U2.1's parser, closing the "does the StepFile model actually hold enough to reproduce the file it came from" question with live evidence rather than assumption, plus a mid-step operator redesign that turned "capture the banner losslessly" into "the banner is a per-deployment runtime value, not step-file data". Step: 11 -- promote UoW **U2.2** (end_to_end.md §4, P2) to execution depth: `app/ledger/emitter.py` (`emit_step_file`), a new `app/config/` package (the banner's runtime home), and `tests/test_ledger_roundtrip.py` -- THE round-trip oracle as a regression suite, byte-identical over the b3u + b3ubot corpus and structural over hand-built "generated skeleton" StepFiles. Parent: end_to_end.md §4 U2.2 bullet verbatim ("Emitter + THE round-trip oracle -- emit(parse(file)) == file, byte-stable, over the corpus and over generated skeletons. Done when: the oracle is a regression suite (this is the ledger's .fgr/.ubr moment)"). The ".fgr/ .ubr moment" references cppcc's own compile-decompile- bytediff discipline (cppcc/CLAUDE.md's "Run"/"Tests" sections, scripts/roundtrip.sh) -- same DISCIPLINE (an oracle that regenerates and byte-diffs), completely different DATA FORMAT (binary grammars there, plaintext step files here); no cppcc code ported, only the shape. app/ledger/parser.py (U2.1, step.10) for the `StepFile`/ `Section` model this step inverts. UoW: U2.2 -- "Emitter + byte round-trip oracle". Second UoW of P2 (the ledger engine, Track A). Depends on U2.1 (done, step.10) for the `StepFile` shape to emit FROM. Operator-confirmed sequencing: finish P2 (U2.2 -> U2.3 -> U2.4) before P3/U3.1. Unblocks U2.3 (ledger-table row operations) and U2.4 (retro-pair/cdiff machinery, the `b3ubot ledger` CLI), both of which read/write through this step's emit path. Date: 2026-07-20 (drafted and executed in the same pass, at the operator's explicit request -- the step.7/step.9/ step.10 single-pass rhythm; a mid-pass operator redesign message on the banner's sourcing was absorbed BEFORE any emitter code was finalized, not bolted on after, see (1)). Origin: Operator directive 2026-07-20: continue the P2 sequence with U2.2 -- explicit brief naming the emitter, the round-trip oracle as a regression suite, and a SPECIFIC risk to investigate first (does StepFile actually retain enough for byte-identity). A follow-up operator message, sent mid-task before any emitter code was written, redesigned the banner from "capture it losslessly in the parsed model" to "runtime-injected per-deployment config value, sourced fresh at emit time" -- absorbed as part of this same single pass. ## (0) Status EXECUTED in this pass. Not a skeleton-then-later-execute split -- the emitter, the new app/config/ package, the tests, the docs updates, and this step file were built together, with the operator's mid-task banner-redesign message absorbed before any emitter code was finalized (the investigation below was already under way when it arrived; the blank-line/trailer findings it did NOT change). ## (1) Ground truth probed at drafting (outputs -> named writers) READ end_to_end.md §4 U2.2 bullet, app/ledger/parser.py in full (the StepFile/Section model + parse_step_text/ parse_step_file this step inverts), app/ledger/errors.py (the refuse-loudly exception shape -- this step adds no new exception type; app/config/loader.py is tolerant of malformed config.txt lines rather than refuse-loudly, because config.txt is OUR OWN shipped file, not untrusted external input like a step file -- a deliberate, named asymmetry, not an oversight), docs/step_file_schema.md, steps/step.10.txt + step.10.diff.txt (direct predecessor and closest template). INVESTIGATED the SPECIFIC risk the operator's brief named first, before writing any emitter code: does StepFile (U2.1) actually retain enough to reproduce a file byte-for-byte? Ran a fresh 60-file survey (the same 50 b3u + this repo's own 10 files step.10 surveyed, re-verified live rather than trusted from that step's notes -- step.10's own lesson #1, "a corpus survey has to actually run the check, not trust a number carried forward"). Found it is a REAL problem, worse than the brief's own framing in one respect and cleanly resolvable in another: (a) The INTERNAL banner: present in 60/60 files, and (before the operator's redesign message, see below) byte-IDENTICAL across all 60 -- 0 variation. Not captured by StepFile at all (confirmed: no banner field, parser.py's own docstring already called it "decorative"). (b) Multi-line TITLES: 52/60 files wrap the title onto indented continuation lines. `title_line` (first line only) drops them entirely -- worse than the operator's brief anticipated, which focused on the banner and section blank-lines, not the title. Found by grepping line 2 of every corpus file for non-banner, non-blank content. (c) Section-boundary blank lines: `Section.body`'s `.strip("\n")` cannot distinguish 0 leading blank lines from 1 -- confirmed via a full 534-transition survey: 532/534 have exactly 1, but 2/534 have 0 (a wrapped SECTION-title continuation landing as the first line of the next section's content -- b3u/steps/step.6.txt section (3), step.25.txt section (1)). Exactly the risk the brief named, confirmed real with hard numbers, not assumed. (d) A related finding NOT in the original brief: U2.1's `body_end` for the LAST section of a file ran to end-of-file, so `Section.body` for that section silently embedded the document trailer ("End of step." etc) as trailing content -- an accidental side effect, never documented, never exercised by U2.1's own tests. Found while building the emitter's reconstruction formula and hand-verifying it against real bytes (the "structural round-trip against a hand-built StepFile" test, see (2), initially failed on exactly this -- see step.11.diff.txt). All OTHER structural gaps checked and found UNIFORM (0 anomalies across the full corpus): line endings (60/60 LF-only, no \r), blank-line count between title and banner (60/60 = 1), banner length (60/60 = 2 lines), blank-line count between banner and header (60/60 = 1), blank-line count between header and the first `## (0)` marker (60/60 = 1), blank-line count between a section's content and the next marker (474/474 non-last transitions = 1), blank-line count before the trailer (60/60 = 1), trailing content after the trailer (0/60 -- file always ends immediately after it), trailing whitespace on marker lines (0/60). This is what made a targeted, additive StepFile extension the right call (option (b) in the operator's brief) rather than either (a) a fixed hardcoded template (would silently corrupt the 2 wrapped-title-continuation files and every multi-line-title file) or (c) downgrading the whole corpus oracle to structural-only (the corpus DOES support byte-identity once the model captures enough -- verified live, see (3) G1/G2). RECEIVED (mid-investigation, before any emitter code was written) an operator design-change message: the banner is NOT step-file data captured/reproduced from source -- it is a per-deployment RUNTIME value, injected fresh at emit time from a new `app/config/` package (`config.txt`'s `banner = "${HOME}/BANNER.md"`, expanded, read if present). This SUPERSEDED the original brief's framing (which asked to investigate whether the banner needed to be captured LOSSLESSLY in the parsed model) with a cleaner design: no StepFile field for it at all, nothing to reproduce FROM the source text, "no BANNER.md -> no banner block" as the explicit absent-case contract. Absorbed before finalizing (b)/(c)/(d) above, which the redesign did NOT change -- those are about title/section/trailer text, orthogonal to the banner. READ app/egress.py ($B3UBOT_HOME env-var-with-default pattern) and b3u/config/b3u_payments.txt + b3u/app/billing.py's payments_config() (a DIFFERENT, stricter, positional-value config format for a DIFFERENT purpose) -- confirmed via direct read that the operator's own instruction not to force-fit that precedent was correct: b3u_payments.txt is "$49.00 -- comment" positional-with-`--`-comments, genuinely a different shape from this step's `key = "value"` spec. app/config/loader.py does not reuse it. READ tests/test_ledger_parser.py (U2.1's own test file) for the $B3U_SIBLING_HOME resolution pattern and the parametrized- per-file corpus test shape -- mirrored exactly in tests/test_ledger_roundtrip.py rather than reinvented. CONFIRMED `git log --oneline -25` for exact commit-message phrasing (step.7/step.9/step.10's two-commit shape is this step's template) before writing either commit. VERIFIED (live, via a standalone script before writing the pytest suite) that the emitter as designed round-trips all 60 real corpus files byte-identically with a fixture BANNER.md matching the corpus's own banner text, and correctly omits the banner block entirely when no BANNER.md is present -- both proven BEFORE writing the regression-suite tests around them, not assumed then tested. ## (2) What U2.2 delivers 2.1 **app/config/** (new package): `config.txt` (checked in, the operator's literal `key = "value"` spec; first and only entry `banner = "${HOME}/BANNER.md"`), `loader.py` (`load_config(path)` -- general key/value reader, `${VAR}` env expansion via `os.path.expandvars`, tolerant of malformed lines/missing file; `banner_path()` -- resolved+expanded Path; `banner_text()` -- the configured file's content with trailing newlines stripped, or `None` if the file doesn't exist). General by design (not a banner-only special case) per the operator's own "later we may add more b3ubot/app config parameters" framing, even though `banner` is the only key today. 2.2 **app/ledger/parser.py** (extended, additive): `Section.raw: str | None = None` (verbatim, unstripped section-body span, trailer excluded for the last section), `StepFile.title_raw` and `StepFile.header_raw: str | None = None` (verbatim multi-line title-block and header-block text, INTERNAL banner excluded via a new `_split_preamble()` helper). All `None` by default -- every U2.1 field, call site, and test keeps working unchanged (verified: tests/test_ledger_parser.py's test BODIES are byte-unedited; its own self-inclusive corpus parametrization naturally grows from 70 to 71 items now that step.11.txt exists on disk at collect time, same self-inclusive growth step.10 documented for step.9->step.10; all 71/71 green). A related fix folded into the SAME additive change: `_parse_sections`'s last-section `content_end` now excludes the trailer (see (1)(d)) for BOTH `body` and `raw`, not just `raw` -- verified against U2.1's one existing body-content assertion (the "2cf5c0f" substring check in test_known_good_file_field_and_section_shape), unaffected. 2.3 **app/ledger/emitter.py** (new): `emit_step_file(StepFile) -> str`. Sources the banner from `app/config.banner_text()`, never from the StepFile it's given (there's nowhere to read one FROM -- no banner field exists). Two-tier reconstruction: prefers `title_raw`/`header_raw`/`Section.raw` when populated (byte- identical path, real parsed files); falls back to synthesizing from `title_line`/`header_fields`/`Section.body` when they're `None` (structural-round-trip path, hand-built "generated skeleton" StepFiles) -- `_format_header_fields()` uses the corpus's own empirical 10-column label alignment (`"Step: "`, `"Parent: "`, etc, verified across all 60 files) for that fallback only; real parsed files never reach it. 2.4 **tests/test_ledger_roundtrip.py** (new): THE round-trip oracle as a regression suite, the done-when's literal wording. (a) byte-identical, parametrized per-file, over all 50 real b3u corpus files (graded fixture, same $B3U_SIBLING_HOME convention as U2.1) -- 50/50 PASS live. (b) byte-identical over this repo's own 11 step files (bonus, self-inclusive of step.11.txt itself) -- 11/11 PASS live. (c) explicit banner-present / banner-absent / different-deployment's-own-banner emit-path tests (not just incidental to (a)/(b)'s fixture). (d) structural round-trip over 4 hand-built "generated skeleton" StepFiles covering: a minimal single-section skeleton, a full 7-section skeleton with UoW, a skeleton with UoW ABSENT (mirroring b3u's own founding step.1.txt), and a skeleton with internal blank lines inside a section body (paragraph breaks) -- confirming the emitter/parser pair round-trips REAL structural variety, not just a happy path. 2.5 **tests/test_config_loader.py** (new): app/config/loader.py in isolation -- key/value parsing, `${VAR}` expansion, comment/ blank-line tolerance, missing-file tolerance, `banner_path()` default expansion, `banner_text()` present/absent/empty-file. 2.6 **docs/step_file_schema.md** same-commit edit: new "What U2.2 needed on top of U2.1's model (step.11)" section -- the title/section/trailer findings from (1), the banner's runtime sourcing, and the two-guarantee (byte-identical vs structural) round-trip shape, named explicitly per the operator's "do NOT silently redefine byte-stable" instruction. 2.7 **end_to_end.md** same-commit edits: §4 U2.2 bullet -> DONE (step.11) with a `*Delivered:*` line naming the ACTUAL achieved guarantee (byte-identical over the corpus, structural over generated skeletons) and the corpus pass counts; §11 ledger row -> done/step.11/PENDING (backfilled at the gdiff commit, the step.7/step.9/step.10 resolution of the self-reference problem). EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not silently built): - Ledger-table row operations / status transitions (the §9-table model: todo -> active -> done/dropped) as an API. -> U2.3 - Retro-pair/cdiff templating machinery, the `b3ubot ledger` CLI surface. -> U2.4 - P3's orchestrator, the DAG-compilation step, anything past P2. -> P3 - design.md §3's Data Model (`Step (UoW)` SQLite entity, the five-layer verbal->SGDL->SQLite->OO-API->CLI pipeline) -- Track B, P4, unrelated to this step's plaintext-file emit/round-trip. -> P4 ## (3) Verification gates G1 All 50 real b3u corpus files (step.1.txt..step.50.txt) round- trip byte-identically: `text == emit_step_file(parse_step_file (path))`, verified live via pytest parametrization against a fixture BANNER.md matching the corpus's own banner text (verified live: 50/50 PASS). G2 This repo's own 11 step files round-trip byte-identically too (bonus, not graded; self-inclusive of step.11.txt once written) (verified live: 11/11 PASS). G3 Banner sourcing proven both ways: banner-configured emits the banner block; no-BANNER.md emits NO banner block at all (not a placeholder) -- both asserted explicitly, plus a third case proving a DIFFERENT deployment's own banner text (not the corpus's) is what gets emitted, the actual point of the mechanism (verified live: PASS, 3/3 dedicated tests plus every G1/G2 case implicitly exercising the present path). G4 4 hand-built "generated skeleton" StepFiles (minimal, full 7-section, UoW-absent, internal-blank-lines-in-body) each satisfy `parse(emit(skeleton))` == the same header fields / section numbers+titles+bodies/ trailer as the original -- verified live: 4/4 PASS. G5 app/config/loader.py unit-tested in isolation: key/value parsing + env expansion, comment/blank tolerance, missing-file tolerance, banner_path() default, banner_text() present/ absent/empty (verified live: 9/9 PASS). G6 NO REGRESSION: U2.1's own test file (tests/test_ledger_parser.py) unedited and fully green; the parser.py extension is additive- only (verified live: 71/71 PASS -- grew from step.10's own 70 because that file's own self-inclusive corpus parametrization now also picks up step.11.txt at collect time, the same growth step.10 documented for step.9->step.10, not a new test written by this step). Full suite: 125p/11s (post-step.10 baseline) -> 205p/11s -- +80 passed (79 genuinely new in the two new test files + 1 from that self-inclusive growth), 0 skipped delta, 0 failures. G7 end_to_end.md §4/§11 updated same commit as the work (2.7); ledger row carries `PENDING` for the commit hash, backfilled at the gdiff commit (the step.7/step.9/step.10 resolution). G8 Hygiene: b3ubot porcelain clean after each commit; retro via the SHARED `v3_claude/scripts/step_gdiff` (not a b3ubot-local copy -- confirmed again per the step.8/step.9/step.10 finding). ## (4) LOCKs C-11-A b3ubot porcelain clean after each commit. C-11-B READ-ONLY CORPUS: the b3u step files are read from `../b3u/steps/` IN PLACE via `$B3U_SIBLING_HOME` (same convention U2.1 established) -- never copied, never written to, never modified by any test. C-11-C NO SILENT SCOPE CREEP: U2.3 (ledger ops), U2.4 (retro-pair/ CLI machinery), and P3/P4 are named out of scope in (2) above, not silently begun. C-11-D U2.1's MODEL EXTENSION IS ADDITIVE AND FLAGGED: every new StepFile/Section field defaults to `None`/absent; no existing field's TYPE changed; the one BEHAVIOR change (last-section `body` no longer embeds the trailer, (1)(d)) was verified against U2.1's own tests before landing, and is named explicitly here and in docs/step_file_schema.md, not silently slipped into an "additive" change. C-11-E THE BANNER IS NEVER STEP-FILE DATA: no StepFile/Section field stores banner text; `emit_step_file` sources it exclusively from `app/config.banner_text()`, never from whatever was in a parsed source file's own bytes. Verified by construction (grep of parser.py/emitter.py finds no banner-content field) and by test (a different deployment's banner text, NOT the corpus's, round-trips correctly, G3). C-11-F "BYTE-STABLE" IS NOT SILENTLY REDEFINED: the corpus achieves true byte-identity (G1/G2); the WEAKER structural guarantee applies ONLY to hand-built "generated skeleton" StepFiles, which have no original bytes to be identical to -- named loudly in this file, docs/step_file_schema.md, end_to_end.md's *Delivered:* line, and app/ledger/ emitter.py's own module docstring, per the operator's explicit "do NOT silently redefine... a downgrade is a real, reportable finding" instruction. C-11-G NO GENERIC EXCEPTIONS in app/ledger/ (inherited from U2.1, C-10-D) -- this step adds no new ledger exception type. app/config/loader.py is DELIBERATELY tolerant (not refuse-loudly) of malformed config.txt lines -- a named, reasoned asymmetry (C-11-H), not an inconsistency with C-10-D. C-11-H app/config/ IS TOLERANT BY DESIGN, NOT REFUSE-LOUDLY: unlike app/ledger/'s parser (untrusted external step-file input, refuses loudly per design.md §2.7), config.txt is OUR OWN shipped file -- a malformed line is silently skipped, a missing file yields an empty config (safe defaults apply). Revisit only if app/config/ ever parses something an external party supplies. C-11-I standing locks inherited: B-3 (no disclosure/no push); B-6 (this step's gates are T0/T1 deterministic pytest assertions; no AI review tier involved). B-1/B-2/B-4/B-5 are moot -- this step invokes no cppcc, no provider, no egress. ## (5) Open questions Q-11-A Should app/config/config.txt eventually validate its `key = "value"` lines strictly (refuse-loudly) once a SECOND real key exists, rather than silently skip unrecognized shapes? LEAN (built this way): no, not yet -- C-11-H's reasoning (our own shipped file, not untrusted input) holds regardless of key count; revisit only if app/config/ starts parsing something a customer/operator hand-edits in a way that could plausibly typo silently (the current single `banner` key is low-stakes: a typo'd line is simply ignored and the default path is used, which is itself the "no BANNER.md" safe case). Q-11-B Should `Section.raw`/`StepFile.title_raw`/`header_raw` replace `body`/`title_line`/`header_fields` outright rather than living alongside them? LEAN (built this way): no -- U2.3's ledger-table operations (design.md §2.3, this step's own out-of-scope list) will likely want the FLATTENED `header_fields` dict / stripped `body` for reading Status/Acceptance/Hash-backfill content, not the verbatim raw text; keeping both costs one extra `None`-default field per structure and zero API churn for U2.1's existing consumers. Revisit only if a real U2.3 need shows the raw fields alone would have sufficed. Q-11-C Does the 10-column header-label-alignment constant (`_HEADER_LABEL_WIDTH` in emitter.py) belong in app/ledger/parser.py's `KNOWN_HEADER_FIELDS` neighborhood instead, since it's schema-shaped knowledge? LEAN (built this way): no -- it's a FALLBACK formatting choice for hand-built StepFiles only (real parsed files never reach it, since `header_raw` is always populated), which is emit-side concern, not parse-side schema; kept in emitter.py, documented as empirical/fallback-only in its own docstring. ## (6) Acceptance - [x] app/config/config.txt + app/config/__init__.py + app/config/loader.py (2.1, G3, G5). - [x] app/ledger/parser.py extended additively (Section.raw, StepFile.title_raw/header_raw, trailer-exclusion fix) (2.2, G1, G2, G6). - [x] app/ledger/emitter.py (2.3, G1-G4). - [x] tests/test_ledger_roundtrip.py + tests/test_config_loader.py (2.4, 2.5, G1-G5). - [x] docs/step_file_schema.md "What U2.2 needed..." section (2.6). - [x] All 50 b3u corpus files round-trip byte-identically -- verified live, 50/50 (G1). - [x] This repo's own 11 step files round-trip byte-identically -- verified live, 11/11, self-inclusive of step.11.txt (G2). - [x] Banner present/absent/different-deployment paths all proven -- verified live, G3. - [x] 4 generated-skeleton structural round-trips -- verified live, G4. - [x] app/config/loader.py unit tests -- verified live, 9/9 (G5). - [x] Zero regressions: U2.1's test bodies unchanged and green, 125p/11s -> 205p/11s (G6). - [x] end_to_end.md §4 U2.2 bullet -> DONE (step.11) with *Delivered:* line; §11 ledger row -> done/step.11/PENDING (2.7, G7). - [x] Retro pair: this file + step.11.diff.txt + step.11.gdiff.txt via the SHARED `v3_claude/scripts/step_gdiff` (G8). ## (7) Hash backfill WORK commit (config + emitter + parser extension + tests + docs + this file + ledger): 41a3749 GDIFF commit (step.11.gdiff.txt via scripts/step_gdiff): (HEAD) End of step.