Payload build MUST read from git, not the working tree
TASK-IMP-127: Payload build MUST read from git, not the working tree
Summary
build.sh assembles the payload by copying from the working tree, so any untracked or gitignored file sitting in modules/ ships in the payload and changes rules_sha. The same commit therefore produces different fingerprints on different machines, which defeats the drift detection rules_sha exists to provide. Drive the payload copy from git so the payload is by construction the tracked content at that commit, and fail the build when the module tree is dirty.
Problem
rules_sha is defined by TASK-IMP-074 as a content fingerprint over the distributed rule trees, and update-check.sh reports RULE DRIFT when an installed machine's fingerprint differs from the payload's at the same version. That signal is only meaningful if the fingerprint is a function of the commit.
It is not. build.sh:28-74 assembles the payload with cp / cp -R from $repo/modules/..., never consulting git, and build.sh:376 computes rules_sha by walking the resulting output tree. Anything a developer happens to have in modules/ is copied in and hashed.
Measured on 2026-07-20 against 069d4dff: a local build produced a9c848e5, CI produced 8745b0fd. The diff is six files, all present locally and absent in CI, all inside the fingerprinted cuo tree - one .DS_Store (matched by .gitignore:15, so gitignored files are not excluded either) and five files of a code_audit_validator.egg-info/ directory left by a local editable install. Zero content differences on any shared path: the payloads are functionally identical and only the fingerprint diverges.
The consequence is a false positive that is indistinguishable from a true one. A fingerprint that varies with untracked local state cannot tell "the rules changed" from "someone opened a folder in Finder", so RULE DRIFT stops being evidence of anything. It is not theoretical - installing a locally built payload across the estate on 2026-07-20 put 20 repos on a9c848e5, every one of which would have reported drift against the released payload with no rule change behind it.
Proposed Solution
Make the tracked content at HEAD the only thing that can enter the payload:
- Drive the payload copy from git rather than the filesystem -
git archivefor whole subtrees, orgit ls-filesto enumerate what may be copied - so untracked and gitignored files cannot be selected at all. - Fail the build when the module tree carries untracked files, rather than silently absorbing them, so the operator learns at build time instead of at a drift report weeks later.
Alternatives Considered
- An
--excludelist for.DS_Storeand*.egg-info. Rejected: it fixes the two extensions observed today and stays silent on the next one. The defect is that the build selects by "what is on disk" instead of "what is committed"; excluding known offenders leaves that selection rule intact. - Compute
rules_shafrom git object hashes instead of the output tree. Rejected as the primary fix: it would make the fingerprint reproducible while still shipping junk files in the payload. Correct the payload, and the fingerprint follows. - Have the release job compare its
rules_shaagainst a local build and fail on mismatch. Kept as a guardrail (below), not as the fix - it detects the divergence without preventing it.
Success Metrics
- Primary: a build from a dirty working tree (untracked
.DS_Storeand anegg-info/dir planted undermodules/) produces a payload byte-identical to a build from a clean checkout of the same commit, and therefore the samerules_sha. Baseline today: the two differ by six files and the fingerprint changes. - Guardrail: no tracked file is dropped - the payload built from git contains exactly the file set the current build produces from a clean tree, so the fix cannot silently shrink the payload.
Scope
In scope: how build.sh selects files for the payload, the dirty-tree guard, and arms in test_release_assets.sh.
Out of scope / Non-Goals
- The definition of
rules_shaitself (TASK-IMP-074) - the set of fingerprinted trees is unchanged. - The RULE DRIFT message wording in
lib/update-check.sh. - Re-vendoring the fleet after the fix (an operator-gated action).
- Removing the
.DS_Storeandegg-infofiles currently in the working tree - housekeeping, and the point of this task is that the build must not care whether they are there.
Dependencies
None blocking. Depends conceptually on TASK-IMP-074, which defines the fingerprint this task makes reproducible.
Relationship to TASK-IMP-122 (p1, on_hold). Both tasks concern rules_sha correctness and they are complementary, not overlapping - an implementer MUST NOT treat either as subsuming the other:
- TASK-IMP-122 governs the COMPARISON side: that the installed fingerprint is recomputed rather than read from a stored token, and which paths the cone covers. Its §1.6 Direction 1 fails the build on a path under
$CYthat no cone entry classifies. - This task governs the PRODUCTION side: which files may enter the payload at all.
122 does not fix this defect. The six contaminating files sit at cuo/gates/caf/caf/..., beneath 122's dir:cuo cone entry, so they are classified and hashed - Direction 1 passes them cleanly while they still corrupt the fingerprint. Conversely this task does not deliver 122: a payload built from git is reproducible, but a comparator that recalls a stored token still reports a build rather than a tree.
They compose in one direction worth stating: once the payload cannot carry untracked files, 122's recomputation compares two trees that a clean checkout can reproduce, which is the premise 122's §1.10 ("byte-identical across the cone") assumes and cannot currently rely on.
AI Authorship Disclosure
- Tools used: Claude (Fable 5) running the CyberOS task-author skill inside Cowork.
- Scope: spec drafted from the 2026-07-20 estate sweep. The two fingerprints and the six-file diff were measured directly - a released payload was downloaded from the v1.0.0 assets and diffed against the local
dist/tree - not inferred. The claim "zero content differences on shared paths" is the output ofdiff -rqover the five fingerprinted directories. - Human review: scope and granularity approved at the 2026-07-20 PLAN gate; both HITL gates are recorded human verdicts.
1. Description (normative)
- 1.1
build.shMUST select payload content from git-tracked files at the built commit; a file that is untracked or gitignored MUST NOT be able to enter the payload, regardless of its presence in the working tree. - 1.2 A build of a given commit MUST produce a byte-identical payload, and therefore an identical
rules_sha, whether or not the working tree carries untracked or gitignored files undermodules/. - 1.3
build.shMUST fail with a non-zero exit and name the offending paths when the module tree contains untracked files, rather than absorbing them into the payload. - 1.4 The git-driven copy MUST NOT drop any tracked file the current working-tree copy produces from a clean checkout - the payload file set is unchanged for a clean build.
2. Acceptance criteria
- [x] AC 1 (traces_to: #1.1, #1.2) - a fixture plants an untracked
.DS_Storeand an untrackedegg-info/dir undermodules/, builds, and the payload andrules_shaare byte-identical to a build of the same commit from a clean tree - test:tools/install/tests/test_release_assets.sh::t_build_ignores_untracked - [x] AC 2 (traces_to: #1.3) - a build with untracked files under
modules/exits non-zero and its stderr names at least one offending path - test:tools/install/tests/test_release_assets.sh::t_build_fails_on_dirty_module_tree - [x] AC 3 (traces_to: #1.4) - the file list of a git-driven payload from a clean checkout equals the file list the pre-change build produces from the same checkout - test:
tools/install/tests/test_release_assets.sh::t_payload_file_set_unchanged
3. Edge cases
- A gitignored file whose path is also tracked (added before the ignore rule) is tracked and MUST ship - "tracked" is the test, not "unignored".
- A submodule or symlink under
modules/must be handled the same way the current copy handles it; the selection rule changes, the materialisation does not. - Building from a detached HEAD or a tag (the release path dispatches against a tag) MUST work - the commit is whatever is checked out, not necessarily a branch tip.
- Building from an archive export with no
.gitpresent (a consumer building from a tarball) MUST either work or fail with a message naming the missing repository, never silently fall back to the working-tree copy - a silent fallback would reintroduce 1.1. - The dirty-tree guard MUST scope to the module tree the payload is built from, not the whole repo: an untracked file under
docs/or/tmpis irrelevant and MUST NOT fail the build. - Security-class: the build reads files and executes nothing from them. Enumerating via git rather than the filesystem narrows the attack surface - an attacker who can drop a file in the working tree can no longer get it into a signed release asset.
Audit
§1 — Verdict summary
Four §1 clauses, three ACs, six edge cases including one security-class row. Machine floor (task-lint.mjs) clean on the audited revision. Every factual claim in the spec is measured rather than inferred: the two fingerprints, the six-file diff, and the "zero content differences" claim are outputs of commands run on 2026-07-20, and the spec cites them as measurements rather than asserting them as background fact. TRACE-006 compared per clause below: all four cited tests assert at least what their clause's verb demands.
§2 — Findings (all resolved)
ISS-001 — XCHAIN: adjacent p1 task on the same axis was not declared
The corpus carries TASK-IMP-122 (rules_sha must be recomputed, not recalled, p1, on_hold) governing the same fingerprint. The spec's related_tasks named only TASK-IMP-074, so an implementer picking this up had no signal that a second p1 task touches rules_sha, and could plausibly have concluded that 122 subsumed this work or vice versa. Material: an unrecorded cross-task constraint. Resolved: related_tasks extended to [TASK-IMP-074, TASK-IMP-122], and Dependencies now carries an explicit complementarity statement with the reason neither subsumes the other — 122 governs the comparison side, this task the production side.
ISS-002 — The claim "122 would catch this" was checked and is false
Before accepting ISS-001's framing, 122 §1.6 Direction 1 was tested against this defect: it fails the build on a path under $CY that no cone entry classifies. The six contaminating files sit at cuo/gates/caf/caf/..., beneath 122's dir:cuo entry, so they ARE classified and hashed — Direction 1 passes them while they corrupt the fingerprint. Had this not been checked, the two tasks could have been merged on a false premise and the defect left unfixed. Resolved: recorded in Dependencies as the specific reason 122 does not deliver this task.
ISS-003 — §1.1's prohibition needed to bind on gitignored files, not only untracked ones
.DS_Store is matched by .gitignore:15 and was still copied. A clause phrased only against "untracked" files would leave the observed defect half-uncovered, since a gitignored file may or may not also be untracked depending on history. Resolved: §1.1 binds on "untracked or gitignored", and §3 carries the inverse edge case — a gitignored path that is nonetheless tracked MUST ship, so the test is tracked-ness, not ignore-status.
ISS-004 — §1.4 was needed to stop the fix shrinking the payload
A git-driven copy that silently omitted a tracked file would satisfy §1.1–§1.3 perfectly while breaking the product. The success metric named this as a guardrail but no clause bound it. Resolved: §1.4 added ("MUST NOT drop any tracked file the current working-tree copy produces from a clean checkout"), traced by AC 3 which compares file sets rather than sampling.
ISS-005 — the no-git build path was an unhandled failure mode
A consumer building from a release tarball has no .git. Absent a clause, the natural implementation falls back to the filesystem copy — silently reinstating §1.1 in exactly the environment where nobody would notice. Resolved: §3 edge case requires that path to work or fail naming the missing repository, and explicitly forbids a silent fallback.
ISS-006 — the dirty-tree guard needed a scope bound
§1.3 as first drafted would have failed a build because of an untracked file anywhere in the repo, including docs/ and scratch files, making it hostile enough that an implementer would weaken it. Resolved: §3 edge case scopes the guard to the module tree the payload is built from.
§3 — TRACE-006 semantic sufficiency (per clause)
| Clause | Verb demands | Cited test asserts | Verdict |
|---|---|---|---|
| 1.1 select-from-git / MUST NOT enter | absence of the untracked file in the produced payload | AC 1: payload byte-identical to a clean-tree build with .DS_Store + egg-info/ planted — byte-identity entails their absence | sufficient |
| 1.2 MUST produce byte-identical payload and fingerprint | equality of both artefacts across dirty and clean trees | AC 1: asserts both the payload bytes and rules_sha | sufficient |
| 1.3 MUST fail non-zero AND name paths | two observables — exit code and stderr content | AC 2: asserts non-zero exit AND that stderr names ≥1 offending path | sufficient on both halves |
| 1.4 MUST NOT drop tracked files | set equality, not sampling | AC 3: compares the full file list of git-driven vs pre-change build | sufficient |
§4 — Resolution
Six findings, all material, all resolved in the audited revision. Machine floor clean. Score = 10/10.
Status transition draft -> ready_to_implement is authorised by this verdict per STATUS-REFERENCE.md §1.1. The two human-acceptance gates downstream are unchanged and remain recorded human verdicts.
End of TASK-IMP-127 audit.