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.
