# G-171 Routine builds never write tracked paths - explicit commands own VCS-visible changes Status: proposed Scope: src/make.tcl (bake/bakehouse/vfslibs - VFSPAYLOAD + PACKAGES phases, kit assembly, new explicit materialize/tidy commands); src/make.tcl sync_layouts phase (the mint-side tracked-path write) with its targets src/project_layouts/ and src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/project_layouts/ plus the workflow_text TERMINOLOGY/DIAGRAM 1b stage-verb assignment; src/vfs/ (payload declarations, _targets staging generalization, README.md + AGENTS.md precedence spec); src/_bake (assembly staging); bin/packages (lib-tier consumption); .gitignore + .fossil-settings/ignore-glob (staging ignore coverage); src/tests/shell/testsuites/punkexe/ (kill-probe + precedence characterization); src/runtime/mapvfs.toml (kit-entry runtime-content mask declarations - 2026-08-08 amendment) Goal: routine build operations (mint, bake/bakehouse/vfslibs and every phase they run) write only to VCS-ignored locations - declared payloads materialize into ignored staging and merge-time layering enacts drop-in-wins precedence identically on every machine - so an interrupted, buggy, or stale-tool bake cannot modify, delete, or shadow tracked files and git/fossil status stays truthful during and after any build; deliberate writes into tracked trees (materializing a fetched/built package into a .vfs folder as drop-in content, retiring superseded committed files, refreshing the thin-layout store) remain available only as explicit separately-invoked commands whose output is an ordinary reviewable VCS change, with help and docs steering to the declaration-based route by default; the same merge-time mask mechanism additionally gives a kit a DECLARED way to exclude runtime-extracted content, so a payload-supplied battery version can ship as the sole indexed candidate instead of losing highest-wins resolution to the runtime's copy (2026-08-08 amendment). Acceptance: from a clean tree, a full bake/bakehouse run - including runs killed at arbitrary mid-run points (kill-probe characterization) - leaves git status and fossil changes reporting nothing outside ignore-covered paths; the VFSPAYLOAD/PACKAGES phases self-check their write destinations and abort before writing any tracked path; a hand-dropped file shadowing a declared payload entry survives assembly with drop-in-wins precedence on a records-free fresh clone (characterized, with the unified regime including _targets documented in src/vfs/README.md); the explicit materialize/tidy command(s) exist and are documented as the sole sanctioned tracked-tree write path, and no bake/bakehouse phase invokes them; a bake -hold run stops after assembly leaving the resolved per-kit images inspectable in staging, and a subsequent plain bake completes to kits identical to a single uninterrupted bake - staged assembly is reused only under digest-confirmed input currency, and stale or hand-modified held state is redone, never shipped; the thin-layout sync writes no tracked path from a mint or bakehouse run - refreshing the layout store and its modpod payload is an explicitly-invoked command producing an ordinary reviewable VCS change, the mint commands detect and report a stale store instead of silently refreshing it (the modules mint packs the tracked modpod tree as it stands), and make.tcl's stage vocabulary names the movement - the layout store documented as the third VCS-tracked consumption tree beside src/bootsupport and src/vfs/_vfscommon.vfs; a kit declaring a runtime-content mask (mapvfs.toml kit-entry runtime_excludes) bakes to an image where the masked runtime paths are absent and the payload-supplied version is the sole indexed candidate (smokerequire proves resolution at the declared version inside the built artifact), punkkit-stamp.toml records the masked paths, boot-critical paths (tcl_library, boot core) are refused at declaration parse, kits declaring no mask assemble byte-identical to pre-mask bakes, and non-zip kit types declaring masks abort with a stated reason. ## Context The kit merge already runs entirely in ignored staging (src/_bake). The only step writing tracked paths is payload materialization: G-115's declarations (achieved 2026-07-31 - see goals/archive/G-115-declarative-vfs-composition.md) automate filling the same .vfs folders that were previously hand-assembled, so machine writes land in tracked space as an evolutionary artifact, not an architectural necessity. Four structural problems follow. (1) Drop-in-wins is enacted in place via punkcheck records that are machine-local and VCS-ignored, so the authored-vs-installed distinction does not travel between machines - replace=true exists to wipe rather than reason, which is exactly the operation that strands trees when a bake dies. (2) Two precedence regimes coexist: in-folder materialization preserves drop-ins, while _targets staging (G-127, achieved - see goals/archive/G-127-crosstarget-vfs-bake.md) merges OVER the per-kit .vfs. (3) Any bake failure mid-materialization leaves tracked files modified/deleted - uninterpretable for a non-developer, and the reason three separate mid-bake-status traps are recorded in project memory. (4) The MINT side carries the same class: the sync_layouts phase of modules/libs/packages/bakehouse copies src/make.tcl, src/build.tcl, the root .gitignore and the bootsupport manifests into the tracked layout store, then the store into the templates modpod source tree - tracked writes issued by a routine build and owned by no explicit command. On a clean checkout they are no-ops, which is why the class went unnoticed; with -dirty-abort 0 a bakehouse mutates tracked layout files from uncommitted source, while its own help states it does NOT run the promotion gates because "payload promotion is a confirm-gated, committed act". Incident evidence 2026-08-06: on a consumer-shaped machine (reset --hard + pull + fetch + bake), a stale untracked bin/punkzip.exe (pre-2.4.0) received the v2.4.0 -b flag, wrote a misdirected 90MB archive to src/-b, the kit-container step failed, and the died bake stranded half-replaced tracked trees across the declared kits - a wall of git modifications/deletions with no user-facing recovery story. Under this goal that whole class strands only ignored staging: recovery = rerun. Vocabulary finding 2026-08-06 (surfaced by the BAKE SUMMARY work, whose mint run synced the layout copies and prompted the question "does the mint terminology cover this?"). make.tcl's stage verbs (G-155, achieved 2026-08-02 - see goals/archive/G-155-mint-stage-vocabulary.md) define mint as version-stamping magic-version sources into the projectroot modules*/lib*/ trees, and promote as propagation into a VCS-tracked consumption tree. The layout sync stamps nothing and lands nowhere near modules*/lib*/, so it is not mint by make.tcl's own definition; its target IS a tracked consumption tree - the src/project_layouts/ store that 'dev project.new' generates from (G-087, achieved - see goals/archive/G-087-thin-project-layouts.md) - so it has promotion's shape, and the release sequence already commits it in step (9) beside the two promotion targets. There are three such trees and only this one has no named command, no gate and no TERMINOLOGY entry: 'sync' appears there purely as a MECHANISM word, so the movement it performs was never assigned a stage and inherited mint by accident of which subcommands call it. The store->modpod copy is the one genuinely mint-internal half - input preparation ordered deliberately BEFORE the source-modules mint that packs #modpod-* trees (verified 2026-08-06: a modules run's freshly packed templates-0.2.0.tm carries the same run's make.tcl, so there is no one-run lag) - and under this goal it moves with the rest into the explicit command, leaving the mint to pack the tracked modpod tree as it stands. Assembly finding 2026-08-08 (surfaced by the punk-kit-on-bi-runtime version question; motivates the runtime-content mask amendment). Zip-kit assembly EXTRACTS the runtime's attached vfs into the per-kit merge tree - the zip extraction comment in make.tcl states why: image assembly replaces the entire attached archive, "so we need the original data to be part of our targetvfs". A bi runtime's batteries therefore pass through the bake as ordinary merge-tree files and are structurally maskable - yet no declared vocabulary reaches them: supersedes/replace run in the VFSPAYLOAD phase against payload trees BEFORE per-kit extraction, so a kit pairing a bi runtime with a lower-versioned payload battery ships BOTH copies and Tcl's highest-wins resolution picks the runtime's (the doctrine recorded in mapvfs.toml's own comments). User direction 2026-08-08: bi kits may carry conservative battery versions while punk kits advance - which needs "ship lower and have it win" as a declared kit property, not a side effect of what happens to be indexed. The merge-time mask mechanism of Approach 2 is the natural home: one mask vocabulary over both the payload layering and the runtime-extracted tree (Approach 11). Cookfs/cookit types edit the payload inside the mounted runtime - no extract-merge moment - so the mask is zip-lane first and other types abort with a stated reason. ## Approach 1. Generalize the G-127 staging pattern: ALL declared payload entries (not just %platform% forms) materialize into ignored staging; the tracked .vfs folder is never a materialization target of any routine phase. 2. Unify precedence at merge time: staging merged first, authored .vfs merged over it (drop-in wins), supersedes/replace expressed as merge-time masks; record the deliberate flip of the current _targets-over-.vfs order; document in src/vfs/README.md; characterize. 3. Explicit commands own tracked-tree writes: a tidy command retiring superseded committed files (report-then-act), and a materialize command that deliberately lands a fetched/built package into a .vfs folder as drop-in content for the developer/builder persona (user direction 2026-08-06) - both producing ordinary reviewable VCS changes, with help text steering to declarations by default. 4. Defense in depth: the phases enumerate write destinations and abort before touching any tracked path. 5. Kill-probe characterization: clean tree in, clean tree out, at arbitrary kill points. 6. Optional interim hardening (independent value if migration is staged): temp-sibling + atomic-swap for the current in-folder materialization, removing the stranded-mid-replace mode before the full move lands. 7. Hold point: 'bake -hold' stops after assembly (post payload-staging + merge + per-target selection, pre container/deploy) with the resolved per-kit images in staging for inspection; a subsequent plain bake completes. Deliberate holds and interrupted bakes unify: any completing run re-verifies staged assembly against per-kit input digests (the G-162 machinery, write-side) and reuses only what is confirmed current. Held staging is machine-owned - inspection is sanctioned, hand-edits are not, and a digest mismatch means redo, not ship. bakelist surfaces held state alongside its freshness verdicts. The project-version gate applies to both invocations. 8. DOX revision: src/vfs/AGENTS.md + README.md - the authored .vfs remains the drop-in surface; the resolved view is src/_bake (optionally a resolved-view report); revise the "operative assembly area" language accordingly. 9. Coordinate with G-162: digest inputs become authored .vfs + staging (or declarations + tier); the digest infrastructure serves both bakelist's read-side verdicts and hold/resume's reuse decision (Approach 7); record the decision in both files. 10. The mint-side write (Context (4)): an explicit command owns the thin-layout sync - refreshing the layout store from src/make.tcl + src/build.tcl + the root .gitignore + the bootsupport manifests, then the store->modpod payload - and modules/libs/packages/bakehouse stop invoking it, detecting and REPORTING a stale store instead (the same shape as the existing stale_bootsupport gate, so the developer learns exactly when the step is due). make.tcl's own vocabulary is corrected in the same work: TERMINOLOGY names the layout store as the third VCS-tracked consumption tree beside src/bootsupport and src/vfs/_vfscommon.vfs - widening 'promote' from what MOVES (minted packages) to where it LANDS (a tracked tree a downstream consumer boots, ships or is generated from) - DIAGRAM 1b marks the store->modpod copy as mint input preparation rather than layout update, and the bakehouse help's "does NOT run the promotion gates" claim becomes true. 11. Runtime-content masks (2026-08-08 amendment; see the Assembly finding in Context): the Approach 2 mask vocabulary generalizes to the runtime-extracted portion of the per-kit merge. A kit entry in src/runtime/mapvfs.toml declares exclusions (e.g. runtime_excludes = ["lib/tcllib2.0"]) applied at merge time AFTER extraction, so a payload-supplied lower version becomes the sole indexed candidate and wins by absence of competition rather than by fighting Tcl's highest-wins selection. Guardrails: boot-critical paths (tcl_library, punkboot core) are refused at declaration parse; interdependent-battery hazards (e.g. tcllibc compiled against a tcllib version) stay the declarer's responsibility with smokerequire as the proving surface (the 2026-07-27 Thread-shadowing incident is the cautionary precedent for silent version substitution). Provenance: a mask is declared config - a deterministic content input, so recording it in punkkit-stamp.toml (a runtime_masks key) is no-volatile-fields safe; the runtime's ridden-in punkbin-artifact.toml then overstates attached_batteries for the masked kit, and the stamp's record of the masked paths is the honest correction - the runtime's own record is never edited. Zip-lane first; cookfs/cookit kits declaring masks abort with a stated reason until an in-place arm exists. ## Alternatives considered - Generalized per-kit ignore lines (punkdeclare pattern for every kit) - workable interim, rejected as endpoint: machine writes still land inside tracked folders, ignore bookkeeping grows per kit, authored-vs-machine mixing persists, and supersedes still mutates tracked files from inside bake. - Hardening only (step 6 atomic swap) - rejected as endpoint: shrinks the window but any bake bug can still mutate tracked files; the class survives. - Requiring committed payload removal first - rejected as sequencing: that is G-004's own arc (artifact coverage per package); this goal works with committed payloads still present and decouples their eventual removal from bake behaviour. - Require-time version pinning instead of runtime-content masks (a declared pin table enforced via the managed ::package override of G-176, achieved 2026-08-08 - see goals/archive/G-176-commandstack-doc-integration.md) - workable and kit-declarable, rejected for this goal's scope: both copies would still ship (image weight, and the demoted copy stays findable by -exact requires), it adds a runtime policy layer where assembly-time absence suffices, and it belongs to the libunknown/commandstack arc if per-consumer version ranges are ever wanted. Also rejected: runtime-variant proliferation as the only answer (a suite-built variant per battery-version combination) - correct for wholesale battery omission, combinatorial for per-kit version selection. ## Notes - Related: G-004 - parent hygiene arc; write-side complement: bake stops being the maintainer of committed payload copies, so G-004's per-package removals decouple from bake behaviour. No dependency on G-004's completion. - Related: G-157 - sibling commit-policy goal; shares the working-modes-stay- first-class posture (uncommitted drop-ins and derived-project permissiveness remain untouched). Its Scope also edits the store->modpod sync exclusion list (bin/sdx.kit), which this goal's explicit layout command inherits. - Related: G-156 - its Scope re-seeds renamed identities through the same sync channels this goal takes ownership of (src/project_layouts + the templates modpod tree); whichever lands first, the other consumes the changed ownership of that channel. - Related: G-162 - input-freshness digests consume payload trees; the staging move changes the digest input set, and the per-kit digest machinery is shared with the hold/resume seam (Approach 7, 9). - Related: G-065 - declarative vendoring is the source-side sibling surface; its sync command is the same explicit-command pattern, and its audit's undeclared-drop-in reporting complements an authored-only .vfs. - Related: G-141 - consumption-driven libfetch selection reads the same *.vfs.toml declarations; unaffected by the write-location move. - Related: G-147 - fetch-side sibling from the same 2026-08-06 evidence (content-keyed tier tree currency). - Related: G-006 - consent posture untouched: this goal moves write locations only; every network-consent gate stays as-is. - Related (archived): G-115 (achieved 2026-07-31 - see goals/archive/G-115-declarative-vfs-composition.md) - the declarative surface consumed here; this goal deliberately revises its "folder remains the operative assembly area" stance: the authored folder stays the drop-in surface, machine-written content leaves it. - Related (archived): G-127 (achieved - see goals/archive/G-127-crosstarget-vfs-bake.md) - the ignored-staging + merge mechanism generalized here, and the origin of the second precedence regime this goal unifies. - Related (archived): G-155 (achieved 2026-08-02, archived) - the _bake/_mint stage-workdir vocabulary this staging extends. - Tool-level fixes from the 2026-08-06 incident (punk::zip accelerator version handshake before emitting v2.4.0-only flags; punkzip strict rejection of unknown dash-flags) harden the trigger and are deliberately outside this contract - this goal removes the blast radius, not the trigger. - Overlap survey 2026-08-06 (goals_xref paths over src/vfs, src/make.tcl, src/_bake, bin/packages): related in substance - G-004, G-141, G-065, G-162, plus policy/evidence siblings G-157, G-147, G-006 added conceptually; surveyed and judged not related in substance: G-089, G-114, G-131 (boot/_config surface), G-019 (payload content trimming), G-020 (Scope names a lib_tcl9 path only), G-116 (tcltls payload constituent - its removal stays G-004-era work). - Follow-on candidate recorded 2026-08-08 (user-directed; becomes a Follow-on line at the flip): dirty-bake provenance indication. A bake from an uncommitted tree records nothing durable today - the stderr PROVENANCE-WARNING is the only trace (punkkit-stamp deliberately carries no VCS/time fields: the G-025 no-time-of-bake DECISION (achieved 2026-08-02 - see goals/archive/G-025-exe-selfreport.md) and the make.tcl no-volatile-fields stamp comment; identical inputs must yield identical bytes). Homes assessed 2026-08-08: (a) near-term - a tree_state = "clean@" | "dirty" field in the advisory kit sidecar (the stamp comment's own designated home for volatile facts, punkbin sidecar precedent; writefile_ifchanged keeps it byte-stable per state), surfaced by bakelist, the BAKE SUMMARY block and ' buildinfo' (a provenance notion beside G-025's stamp/live/governing: attested / unattested / unknown); (b) endpoint - a publication-side content-addressed manifest (kit sha1 -> producing commit) written by an explicit post-step-(9) command in the punkbin publication pattern, where ABSENCE of an entry IS the no-provenance indication - a dirty build cannot be trusted to attest itself, and this goal's records-free-fresh-clone reproducibility (Approach 1/2/5) is what upgrades rebake-and-compare into the manifest's verification step, so (b) sequences AFTER this goal's landing. REJECTED homes: the stamp/payload (determinism principle - phantom staleness across history, no truthful value in the sanctioned step-(6)-(8) dirty window) and the artifact filename (breaks name-keyed deploy/ledger machinery, retroactively false mid-sequence). Extended 2026-08-08 (artifact-revision derivation - user question "must -Dlibrev be manual?"): the same publish command owns lib/family -r revision derivation. The build recipe cannot safely self-derive: revisions bump only on deliberate publish (never per rebuild - auto-increment mints phantom revisions), configure-time probes of the punkbin store would add network/mutable-state inputs (the volatility class the artifacts ban), and the rev rides INSIDE the artifact (the -rN name seeds build_id and the record's revision key), so content-vs-published comparison must build AT the published rev and sha1-compare against the published sidecar - a candidate at rN+1 can never hash-match rN. Publish command verdicts: current (no-op) / differs -> emit at max+1 / differs -> alpha in-situ replace (the punkbin dispensation while zero 3rd-party consumers) - a policy fork the operator asserts on a checked claim, the -expect-projectversion philosophy applied to revisions. Companion convention (the repo's existing half-implementation): the recipe's -D default IS the committed record of published state, bumped in the same work unit as each publish - the familyrev precedent (default 2, "r1 published 2026-07-22") - and librev's drift proves the manual failure mode ("default 1 - nothing published yet" while tcllib2.0-*-r1 is published); repair librev's default+comment at the r2 publish, and the publish command stages that default bump inside its one reviewable unit. - Approach 10 message-shape template updated 2026-08-08 (git b729ce07 / fossil 55a54d8b, project 0.65.1): the stale_bootsupport gate it cites now emits via a shared helper, punkboot::lib::bootsupport_stale_warning - mechanism-first wording that names the launch directory, contrasts src/-vs-elsewhere module precedence, special-cases the repair commands, and prints the resolved src path as a copy-pasteable fix line. The layout-store staleness report should generalize that helper (parameterize the subject tree, stale list and steer-to command) rather than hand-roll a second block. Semantics stay distinct by design: bootsupport staleness is a version-aware abort/prompt (the stale copies are the code executing make.tcl itself), while the layout-store report is report-and-proceed (the mint packs the tracked modpod tree as it stands - the lag is the deliberate, reported state).