# Row 283 (#1788) C2-01d build notes (Honker) Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby. ## 2026-10-23 build on 45dca69f6 (sage-3577) Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4 (d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's. Commits: - 331653b38: code, README and tests T1-T8. - 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4). - this file. Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count below is a plan until Sage's CLEAR. ### Drift check (sage-3577) `git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or hash-pin: - No change in packages/library, packages/business/src/business.mjs or packages/business/tests/business.test.mjs. - No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and stack-copy.test only checks that index.mjs exists. - webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is the business one, not the library's. I redo the drift check on BASE..refactor before the in-review move. ### N3 (webby-0169): deepEquals of the mapping output outside packages/library `git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming `universes` give two files: - business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with `checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both sides. :27 compares only `.playground`. - library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the mapping output. No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains `supporting: []`, as the design says. ### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4) survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test (survey.test "C2-01d: with --mapping the survey counts supporting files"). mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c: - old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',` - new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',` N2: the pin gains only `supporting 0`. No other text on the line or in that test moved. ### Q2 tradeoff (webby-0169) A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting first, so that playground prefix could never match. The cost is that she can't nest a playground under a supporting folder, though she can nest the other way (a playground holding a supporting folder). ### Handoffs - Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea #1793 comment 30738, with tests for a missing path and a non-100644 entry. - A save with no `editor` refusing is C2-02's (DESIGN.md section 1). ### Choices the design left open - The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The playground's messages and codes are byte-equal to before. - A non-string path refuses `library-write-story-path` ("the path must be a string") as part of check 1. A non-array `tree` is a TypeError (DESIGN.md section 3). - Check 8 builds the tree's names as every path and every folder above it, keyed by `normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than itself, so a tree that already holds case twins refuses a write to either (R7). ### Mutants (exact strings) Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --` on the one file before the next. | Mutant | File | Replace | With | Killed by | |---|---|---|---|---| | M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) | | M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 | | M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" | | M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" | | M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) | | M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" | | M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" | | M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" | | M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" | | M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) | (The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named, plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red on the named test and the restored file's sha256 equals the build tip's. ### Gate plan (on Sage's CLEAR) 1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and survey tests, and business.test.mjs. Counts go below. 2. M1-M8 as above. 3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and `node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups). 4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed message text, then the drift check on BASE..refactor. ## 2026-10-23 Webby early look (webby-0227, static, not a verdict) - E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal. The in-review request lists it next to survey.test:73. - E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any non-string) path refuses `library-write-story-path` instead of throwing a TypeError. supporting.test's string case now covers 7, null, undefined, 1n and an array. - E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing `value.trim() !== value` would change that approved rule, so it went to Sage instead of into the build. ## 2026-10-23 E2 departure (sage-3601) A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is "non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds `if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner whitespace is kept ("Her Middle Name" passes). T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner whitespace pass. M6 grows a third part: | Mutant | File | Replace | With | Killed by | |---|---|---|---|---| | M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) | Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`, leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json) matches once. No fixture maps an editor or author with surrounding whitespace. ## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2 Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress). Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud() rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`. The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses library-mapping-invalid with "editor. must not start or end with whitespace or any of , : ; " ' \". `trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone. Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline, tab and every other control anywhere in the value. T3 now pins that at the start, the end and the middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too. Tests: - T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle. Four accepted names have inner crud characters. - New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It commits in a scratch repo with editor `Doe, Jane Q.` / `jane.doe@example.invalid.` as author and committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log` shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came back unchanged. M6c now replaces the whole end check (this supersedes the earlier M6c row): | Mutant | File | Replace | With | Killed by | |---|---|---|---|---| | M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) | | M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) | ## 2026-10-23 E5 (webby-0236, not blocking), fixed The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*` ident still applies. Test only; no mutant changes. ## 2026-10-23 sage-2925: optional editor.user (sage-3649) sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283 adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02) passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval (ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys `name` and `email`"). DESIGN.md stays unedited. - mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009). - README: the `editor` line names `user` and the end-character rule from E2/E4. - Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in mapping.test with the other editor cases, not in the new supporting.test, which tests write paths. - No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep). | Mutant | File | Replace | With | Killed by | |---|---|---|---|---| | M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) | | M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) | ## 2026-10-23 T2 run (sage-3695 CLEAR, lane A, library and business only) Node v26.8.1. refactor was e2b873390 (226 landed); `git diff --stat 45dca69f6 e2b873390 -- packages/library packages/business` is empty. TMPDIR was a mktemp -d under /mnt/storage/scratch/tmp, with pipefail. Singles and mutants ran under `timeout -k 10 600`, suites under `timeout -k 10 1800`. First-run red (mos-1272, placed): the first base run on 46bad7ea3 at 20:01:09Z was red in supporting.test "malformed paths refuse by class, in order". 5 of 6 passed, with `TypeError: Do not know how to serialize a BigInt`. The cause was the test helper, not the product: `refuses()` labelled each assertion with `JSON.stringify(path)`, and the 1n case added for E3 can't be stringified. In c18ad3e12 the label uses `util.inspect(path)`, a 2-line test-only change. The product's 1n refusal was never in question: supportingPath type-checks before it stringifies (E3). The whole run below is on c18ad3e12. Base, the touched files whole (20:01:43Z), all rc=0: - library mapping.test 16/16, scope.test 6/6, supporting.test 6/6, survey.test 7/7; - business business.test 21/21; - 0 skipped, 0 cancelled, 0 todo. Mutants (20:01:44Z). Each was applied by exact string with count 1, then restored with `git checkout --`. Every restored sha256 equals HEAD's blob, and the tree was clean after. | Mutant | Run | Result | Failing test | |---|---|---|---| | M1 | scope.test | rc=1, 5/6 | supporting comes before playground; a folder in both lists is supporting… | | M1 | supporting.test | rc=1, 5/6 | a path passes at book level, universe level, in a draft and in a folder in both lists | | M2 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code | | M2 | business.test | rc=1, 20/21 | C2-04: library mappings load without roots, remain frozen, and retain mapping refusals | | M3 | supporting.test | rc=1, 5/6 | a symlink or gitlink on the way, or at the path itself, refuses | | M4 | supporting.test | rc=1, 5/6 | a name that differs from a tree name only in case or Unicode form refuses… | | M4b | supporting.test | rc=1, 5/6 | the same case/Unicode test | | M5 | supporting.test | rc=1, 5/6 | a path of exactly 1024 bytes passes, and 1025 bytes… refuses | | M6 | mapping.test | rc=1, 15/16 | editor is optional, kept when given, and refuses anything but a plain name and email | | M6b | mapping.test | rc=1, 15/16 | the same editor test | | M6c | mapping.test | rc=1, 15/16 | the same editor test | | M6d | mapping.test | rc=1, 15/16 | the same editor test | | M6e | mapping.test | rc=1, 15/16 | the same editor test | | M6f | mapping.test | rc=1, 15/16 | the same editor test | | M7 | supporting.test | rc=1, 5/6 | a path outside the universe's supporting folders refuses | | M8 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code | 14 of 14 mutants killed, each on its named test only. Suites (20:01:47Z): - `packages/library`: `node --test tests/*.test.mjs` rc=0, 98/98, 0 skipped. - `packages/business`: `node --test tests/` rc=0, 231/231, 0 skipped. Not run here: every scripts/test-*.sh and the other packages. Those are Sage's 283 gate, after Webby's APPROVE. ## 2026-10-23 G1: mos-1528 OK for the mapping.test draft deepEqual (sage-3713) This entry completes the N3 line above (BUILD.md:42, "only the draft deepEqual at mapping.test:48 gains `supporting: []`"). BUILD.md is append-only, so :42 stays as written. Mos OK'd the change of that old assertion in mos-1528. It is now at packages/library/tests/mapping.test.mjs:52, and commit 331653b38 changed it. Everything else in that commit's hunk is new test lines, not changed old ones. - Old, mapping.test.mjs:48 at 45dca69f6: `assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });` - New, mapping.test.mjs:52 at b2a1f4092: `assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });` The line gains only `, supporting: []`; nothing else in it moved. The business.test and scope.test hunks add tests only (sage-3713: no OK needed). The other old-test change in this row is survey.test:73 (mos-1330 item 4, 28918824c). Doc only.