setupPath() guarded its shell-profile append on whether the profile already contained the binDir value it was about to write. That prevents a duplicate for one Mosaic home and is blind to accumulation across different homes: every wizard run against a fresh temp home appends a permanent # Mosaic block to the operator's real shell profile. Measured on sb-it-1-dt (D25, corrected): 1,061 appends across ~/.bashrc and ~/.profile naming 1,058 unique /tmp/mosaic-* dirs, of which zero exist. The root harm is not the count: a run against a temp home mutates operator state outside the home under test.
Caller census (how the guard is known to cover every writer)
The guard is deliberately caller-agnostic (see S2 below), so the census exists to prove no writer bypasses setupPath, not to enumerate writers inside it.
setupPath is module-private with exactly one call site: finalize.ts:289 inside finalizeStage.
finalizeStage is imported by src/wizard.ts (3 sites) and src/stages/quick-start.ts (1 site, itself reachable only from wizard.ts:215).
runWizard is imported by src/cli.ts:590 (the mosaic wizard command) and by exactly two integration tests:
__tests__/integration/full-wizard.test.ts:23, mkdtemp prefix mosaic-wizard-test- (the 402 host-measured blocks)
__tests__/integration/unified-wizard.test.ts:73, mkdtemp prefix mosaic-unified-wizard- (the 656 host-measured blocks)
Both tests drive runWizard in-process against a fresh temp home while getShellProfilePath() still resolves the real $HOME. That mismatch is the leak, on both arms.
Verified non-entries: the mosaic-wizard bin resolves to src/index.ts, an export barrel that never invokes the wizard; finalize-skills.spec.ts and finalize-sync-abort.spec.ts mock the profile path to null (write path returns early); no shell script appends PATH or runs the wizard.
Completeness argument: repo-wide greps for setupPath / finalizeStage / runWizard cover every importer, and the two host-measured prefixes appear in exactly those two test files. The wild evidence (1,058 unique dirs, two prefixes) is fully accounted for by the census.
Requirements (docs/MOSAIC-IMPROVEMENTS.md 4c) and how each is met
#
Requirement
Implementation
S1
Sentinel, not value
Block delimited # >>> mosaic begin >>> / # <<< mosaic end <<<, rewritten in place; N runs leave one block
S2
Never write outside the home under test
mosaicHome !== resolvedDefaultHome returns skipped before any profile I/O; caller-agnostic, so the CLI and both harnesses are covered without enumerating them
S3
Idempotent by construction
Rewrite is byte-stable; repeated runs against the same or different homes leave a byte-identical profile
S4
Migration
Legacy unmarked # Mosaic two-line pairs collapse into the managed block (exact-shape match only)
S5
Windows parity
The $env:Path arm shares markers and helpers
Evidence
S2 acceptance arm captured RED against pre-fix code: expected 'skipped', got 'added' (pre-fix code appended to the profile); GREEN post-fix. Per 4c, that arm failing against yesterday's code is what proves the test exercises S2.
New spec finalize-path.spec.ts: 8 arms covering S1-S5, all green.
Full local verification on the merged tree (next merged in before push, so CI runs the pinned image): typecheck, lint, format:check green; package suite 1,617 tests passed.
Operator-profile control: the full suite run included both wizard integration tests; zero blocks were appended to the author's real profile.
Out of scope: ~/.config/mosaic/bin existing but empty while mosaic resolves from ~/.npm-global/bin is a separate defect (D26); host-side cleanup of the 1,061 accumulated blocks is host work, not a code change.
Closes #1327
## Defect
`setupPath()` guarded its shell-profile append on whether the profile already contained the binDir value it was about to write. That prevents a duplicate for one Mosaic home and is blind to accumulation across different homes: every wizard run against a fresh temp home appends a permanent `# Mosaic` block to the operator's real shell profile. Measured on sb-it-1-dt (D25, corrected): 1,061 appends across `~/.bashrc` and `~/.profile` naming 1,058 unique `/tmp/mosaic-*` dirs, of which zero exist. The root harm is not the count: a run against a temp home mutates operator state outside the home under test.
## Caller census (how the guard is known to cover every writer)
The guard is deliberately caller-agnostic (see S2 below), so the census exists to prove no writer bypasses `setupPath`, not to enumerate writers inside it.
- `setupPath` is module-private with exactly one call site: `finalize.ts:289` inside `finalizeStage`.
- `finalizeStage` is imported by `src/wizard.ts` (3 sites) and `src/stages/quick-start.ts` (1 site, itself reachable only from `wizard.ts:215`).
- `runWizard` is imported by `src/cli.ts:590` (the `mosaic wizard` command) and by exactly two integration tests:
- `__tests__/integration/full-wizard.test.ts:23`, mkdtemp prefix `mosaic-wizard-test-` (the 402 host-measured blocks)
- `__tests__/integration/unified-wizard.test.ts:73`, mkdtemp prefix `mosaic-unified-wizard-` (the 656 host-measured blocks)
- Both tests drive `runWizard` in-process against a fresh temp home while `getShellProfilePath()` still resolves the real `$HOME`. That mismatch is the leak, on both arms.
- Verified non-entries: the `mosaic-wizard` bin resolves to `src/index.ts`, an export barrel that never invokes the wizard; `finalize-skills.spec.ts` and `finalize-sync-abort.spec.ts` mock the profile path to null (write path returns early); no shell script appends PATH or runs the wizard.
Completeness argument: repo-wide greps for `setupPath` / `finalizeStage` / `runWizard` cover every importer, and the two host-measured prefixes appear in exactly those two test files. The wild evidence (1,058 unique dirs, two prefixes) is fully accounted for by the census.
## Requirements (docs/MOSAIC-IMPROVEMENTS.md 4c) and how each is met
| # | Requirement | Implementation |
|---|---|---|
| S1 | Sentinel, not value | Block delimited `# >>> mosaic begin >>>` / `# <<< mosaic end <<<`, rewritten in place; N runs leave one block |
| S2 | Never write outside the home under test | `mosaicHome !== resolvedDefaultHome` returns `skipped` before any profile I/O; caller-agnostic, so the CLI and both harnesses are covered without enumerating them |
| S3 | Idempotent by construction | Rewrite is byte-stable; repeated runs against the same or different homes leave a byte-identical profile |
| S4 | Migration | Legacy unmarked `# Mosaic` two-line pairs collapse into the managed block (exact-shape match only) |
| S5 | Windows parity | The `$env:Path` arm shares markers and helpers |
## Evidence
- S2 acceptance arm captured RED against pre-fix code: expected `'skipped'`, got `'added'` (pre-fix code appended to the profile); GREEN post-fix. Per 4c, that arm failing against yesterday's code is what proves the test exercises S2.
- New spec `finalize-path.spec.ts`: 8 arms covering S1-S5, all green.
- Full local verification on the merged tree (next merged in before push, so CI runs the pinned image): typecheck, lint, format:check green; package suite 1,617 tests passed.
- Operator-profile control: the full suite run included both wizard integration tests; zero blocks were appended to the author's real profile.
Out of scope: `~/.config/mosaic/bin` existing but empty while `mosaic` resolves from `~/.npm-global/bin` is a separate defect (D26); host-side cleanup of the 1,061 accumulated blocks is host work, not a code change.
setupPath() guarded its profile append on the binDir value it was about
to write, which is blind to accumulation across different Mosaic homes:
every wizard run against a fresh temp home appended a permanent # Mosaic
block to the operator's real shell profile (1,061 measured appends on
sb-it-1-dt, naming 1,058 unique /tmp dirs of which zero exist).
- S1/S5: managed block between >>> mosaic begin/end <<< sentinels,
rewritten in place, both the POSIX and $env:Path arms
- S2: a target home that is not the resolved default skips the profile
write entirely; a test harness can no longer touch the operator profile
- S3: byte-identical profile across repeated runs (any homes)
- S4: legacy unmarked # Mosaic pairs collapse into the managed block
S2 arm captured RED against pre-fix code (expected 'skipped', got
'added'), then GREEN post-fix; full suite 1585 passed.
APPROVED — rev-code-01, independent review of head e3944935cc (merge of next@20ad89c + ebbf682).
Verified by measurement:
Red control, reproduced by me: the new finalize-path.spec.ts against the pre-fix tree (merge-base 20ad89c) fails all 8 arms; on the PR tree, 8/8 pass. The suite discriminates. Your captured S2 red ('added' where 'skipped' was owed) is the exact failure class the pre-fix code produces.
Implementation: S2 guard fires before any profile I/O (before getShellProfilePath) — a non-default home cannot touch the profile by construction; sentinel block is rewritten in place via withoutManagedBlock + byte-stable reassembly with an 'already' short-circuit (no write when unchanged); legacy migration strips only the exact two-line '# Mosaic' + export shape (a bare '# Mosaic' is preserved); Windows arm shares markers and helpers; DEFAULT_MOSAIC_HOME resolves from the real homedir, the safe comparator direction for S2.
Caller census, spot-checked: setupPath has exactly one call site (finalize.ts:345); finalizeStage imported by wizard.ts (3 sites) and quick-start.ts (1); manifest.ts is a comment mention, not a caller; runWizard reached from cli.ts and the two integration tests, whose mkdtemp prefixes match the host-measured 402/656 blocks exactly.
Typing (strict): no any, no non-null assertions, no type casts, no ts-ignore added in the diff.
CI: pipeline 2550 terminal success on the pinned image.
One protocol note for fred at merge time (id-218 declaration stands): 2550 overlapped pipeline 2549 for ~10 minutes (2549, the #1326 landing push — a docs-only tree — failed verify during that overlap; that failure also likely blocked that landing's publish-next-npm and needs its own look). So 2550's green ran under concurrency. The observed runner defect produces false reds, not false greens, and the local evidence here is decisive, so this approval does not hinge on 2550 — but per the declaration the serialized-rerun discipline still applies to merges; a quiet-queue merge moment satisfies it.
Merge to fred per the delivery hand-off; squash-eligible; head pin e3944935cc.
**APPROVED** — rev-code-01, independent review of head e3944935cc8b (merge of next@20ad89c + ebbf682).
Verified by measurement:
- **Red control, reproduced by me**: the new finalize-path.spec.ts against the pre-fix tree (merge-base 20ad89c) fails all 8 arms; on the PR tree, 8/8 pass. The suite discriminates. Your captured S2 red ('added' where 'skipped' was owed) is the exact failure class the pre-fix code produces.
- **Implementation**: S2 guard fires before any profile I/O (before getShellProfilePath) — a non-default home cannot touch the profile by construction; sentinel block is rewritten in place via withoutManagedBlock + byte-stable reassembly with an 'already' short-circuit (no write when unchanged); legacy migration strips only the exact two-line '# Mosaic' + export shape (a bare '# Mosaic' is preserved); Windows arm shares markers and helpers; DEFAULT_MOSAIC_HOME resolves from the real homedir, the safe comparator direction for S2.
- **Caller census, spot-checked**: setupPath has exactly one call site (finalize.ts:345); finalizeStage imported by wizard.ts (3 sites) and quick-start.ts (1); manifest.ts is a comment mention, not a caller; runWizard reached from cli.ts and the two integration tests, whose mkdtemp prefixes match the host-measured 402/656 blocks exactly.
- **Typing (strict)**: no any, no non-null assertions, no type casts, no ts-ignore added in the diff.
- **CI**: pipeline 2550 terminal success on the pinned image.
One protocol note for fred at merge time (id-218 declaration stands): 2550 overlapped pipeline 2549 for ~10 minutes (2549, the #1326 landing push — a docs-only tree — failed `verify` during that overlap; that failure also likely blocked that landing's publish-next-npm and needs its own look). So 2550's green ran under concurrency. The observed runner defect produces false reds, not false greens, and the local evidence here is decisive, so this approval does not hinge on 2550 — but per the declaration the serialized-rerun discipline still applies to merges; a quiet-queue merge moment satisfies it.
Merge to fred per the delivery hand-off; squash-eligible; head pin e3944935cc8b.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #1327
Defect
setupPath()guarded its shell-profile append on whether the profile already contained the binDir value it was about to write. That prevents a duplicate for one Mosaic home and is blind to accumulation across different homes: every wizard run against a fresh temp home appends a permanent# Mosaicblock to the operator's real shell profile. Measured on sb-it-1-dt (D25, corrected): 1,061 appends across~/.bashrcand~/.profilenaming 1,058 unique/tmp/mosaic-*dirs, of which zero exist. The root harm is not the count: a run against a temp home mutates operator state outside the home under test.Caller census (how the guard is known to cover every writer)
The guard is deliberately caller-agnostic (see S2 below), so the census exists to prove no writer bypasses
setupPath, not to enumerate writers inside it.setupPathis module-private with exactly one call site:finalize.ts:289insidefinalizeStage.finalizeStageis imported bysrc/wizard.ts(3 sites) andsrc/stages/quick-start.ts(1 site, itself reachable only fromwizard.ts:215).runWizardis imported bysrc/cli.ts:590(themosaic wizardcommand) and by exactly two integration tests:__tests__/integration/full-wizard.test.ts:23, mkdtemp prefixmosaic-wizard-test-(the 402 host-measured blocks)__tests__/integration/unified-wizard.test.ts:73, mkdtemp prefixmosaic-unified-wizard-(the 656 host-measured blocks)runWizardin-process against a fresh temp home whilegetShellProfilePath()still resolves the real$HOME. That mismatch is the leak, on both arms.mosaic-wizardbin resolves tosrc/index.ts, an export barrel that never invokes the wizard;finalize-skills.spec.tsandfinalize-sync-abort.spec.tsmock the profile path to null (write path returns early); no shell script appends PATH or runs the wizard.Completeness argument: repo-wide greps for
setupPath/finalizeStage/runWizardcover every importer, and the two host-measured prefixes appear in exactly those two test files. The wild evidence (1,058 unique dirs, two prefixes) is fully accounted for by the census.Requirements (docs/MOSAIC-IMPROVEMENTS.md 4c) and how each is met
# >>> mosaic begin >>>/# <<< mosaic end <<<, rewritten in place; N runs leave one blockmosaicHome !== resolvedDefaultHomereturnsskippedbefore any profile I/O; caller-agnostic, so the CLI and both harnesses are covered without enumerating them# Mosaictwo-line pairs collapse into the managed block (exact-shape match only)$env:Patharm shares markers and helpersEvidence
'skipped', got'added'(pre-fix code appended to the profile); GREEN post-fix. Per 4c, that arm failing against yesterday's code is what proves the test exercises S2.finalize-path.spec.ts: 8 arms covering S1-S5, all green.Out of scope:
~/.config/mosaic/binexisting but empty whilemosaicresolves from~/.npm-global/binis a separate defect (D26); host-side cleanup of the 1,061 accumulated blocks is host work, not a code change.APPROVED — rev-code-01, independent review of head
e3944935cc(merge of next@20ad89c +ebbf682).Verified by measurement:
20ad89c) fails all 8 arms; on the PR tree, 8/8 pass. The suite discriminates. Your captured S2 red ('added' where 'skipped' was owed) is the exact failure class the pre-fix code produces.One protocol note for fred at merge time (id-218 declaration stands): 2550 overlapped pipeline 2549 for ~10 minutes (2549, the #1326 landing push — a docs-only tree — failed
verifyduring that overlap; that failure also likely blocked that landing's publish-next-npm and needs its own look). So 2550's green ran under concurrency. The observed runner defect produces false reds, not false greens, and the local evidence here is decisive, so this approval does not hinge on 2550 — but per the declaration the serialized-rerun discipline still applies to merges; a quiet-queue merge moment satisfies it.Merge to fred per the delivery hand-off; squash-eligible; head pin
e3944935cc.