Extends the already-shipped build-time-versioning feature so --version output can distinguish different uncommitted-change states via a short content-based fingerprint, instead of just a boolean -dirty flag. Empirically confirmed during design (live probe against src/flake.nix, reverted) that Nix's self.narHash is reachable at eval-time and content- sensitive, resolving the spec's one open technical question for the production build path without requiring git inside the sandbox. Caught and corrected a design flaw before finalizing: an earlier approach would have retired commit_id's -dirty suffix at its computation site, which traces through to silently break the startup log's existing modified-tree signal (same underlying value). Resolved by leaving commit_id completely untouched everywhere and doing the -dirty-stripping only inside --version's composition, only when a fingerprint is available -- which also satisfies Requirement 5.2's fallback for free. Independent task-graph sanity review (kiro-spec-tasks Step 3.5) returned NEEDS_FIXES on 3 minor items; all fixed and self-verified before approval. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
27 KiB
Design Document
Overview
Purpose: This feature gives every XCE component's --version output the ability to distinguish two builds made from the same commit but with different uncommitted changes — replacing the existing plain -dirty boolean suffix (which cannot tell two dirty builds apart) with a short, content-derived fingerprint.
Users: Developers and operators iterating on a dev-loop build (xce-build.sh) who rebuild repeatedly while editing, and need to tell which specific edit produced the binary in front of them, without falling back to manual file-timestamp or diff inspection.
Impact: Extends the already-shipped build-time-versioning feature's commit_id/build_time mechanism with a third, optional value. commit_id, build_time, and the startup-log output are all left completely untouched — same computation, same value, same call sites. Only the --version-composition call site changes: when a fingerprint is available, it strips commit_id's existing -dirty suffix (if present) and appends the fingerprint instead, producing a clean <commit_id>-<hash> shape without redundant markers; when no fingerprint is available, --version falls back to showing commit_id exactly as computed today (see research.md's "Leave commit_id's computation fully untouched" decision).
Goals
- A dirty build's
--versionoutput distinguishes itself from a different dirty build of the same commit, whenever their uncommitted content differs. - Two builds with identical uncommitted content produce the identical fingerprint, reproducibly.
- A clean build's
--versionoutput is completely unchanged (just the commit identifier, as today). - All six components (controller, scheduler, worker, apiserver, xcecli, xceadmin) get this capability.
- The daemon startup log line is untouched by this feature.
Non-Goals
- Showing which files changed or their diff content anywhere in
--versionoutput (fingerprint only, not a diff viewer). - Extending the startup log line to include the fingerprint (explicitly out of scope per Requirement 3).
- Closing the Nix production path's untracked-file blind spot (documented, accepted limitation — see
research.md). - Any change to
commit_id's computation, value, or its existing-dirtysuffix, or tobuild_time's computation — genuinely unaffected by this feature everywhere. The-dirty-stripping needed for a clean--versionformat is display-only logic inside the--version-composition call, not a change tocommit_iditself.
Boundary Commitments
This Spec Owns
- Computation of a new
dirty_fingerprintvalue at both existing computation sites (xce-build.sh,flake.nix). - Threading that value through the existing single-resolved-value-pair meson-option mechanism as a third option.
- The
--version-only composition logic that combinescommit_idanddirty_fingerprintfor display (including the-dirty-stripping rule, applied only at this one call site — seeresearch.md).
Out of Boundary
- The startup log line's content or format (unchanged; still shows only
commit_id/build_time, per the original feature, via its completely untouched composition call site). - The original feature's
commit_id/build_timecomputation, values, or the-dirtysuffixcommit_iditself carries — genuinely unmodified everywhere, per the corrected design decision inresearch.md. - Any mechanism for an operator to retrieve or view the actual uncommitted diff/file list (out of scope per requirements' Boundary Context).
- Making the Nix path's fingerprint reflect unstaged untracked files (documented limitation, not solved here).
Allowed Dependencies
- The original
build-time-versioningfeature's meson option mechanism (src/meson.options,src/meson.build'sadd_project_arguments()), extended with one new option, not replaced. xce-build.sh's existingcommit_id/build_timecomputation block andMESON_OPTSconstruction (task 2.1 of the original feature).xce.nix/xcecli.nix's existingpreConfigurebuild-phase hook (task 2.2 of the original feature).- Each component's existing version-composition function/mechanism (
xce_compose_version()for C, the Go ldflags vars,_build_info/cli.pyfor Python) — extended with one new optional field, not replaced.
Revalidation Triggers
xce_compose_version()'s signature or call sites change shape (would affect how the new field is threaded through).- The original feature's
commit_id/build_timemeson options are renamed or restructured. xce-build.sh'sMESON_OPTSconstruction pattern changes.self.narHash's semantics change in a future Nix version (unlikely, but the design's Nix-path assumption depends on it).
Architecture
Existing Architecture Analysis
- The original feature established: two computation sites (
xce-build.shfor dev-loop,xce.nix/xcecli.nixfor production) each resolvecommit_id/build_timeinto meson options; six consumption sites each read those options once and never recompute. This feature adds a third value to that exact same shape. - Each of the six components already has exactly one place where its version string is composed for
--versionand, separately (for the four daemons), one place where the startup log's version fields are logged — both currently reading the identicalcommit_id/build_timevalues. This feature's--version-only scope means the--versioncomposition call site gains the new field while the startup-log composition call site does not — the first deliberate divergence between those two call sites in this feature family.
Architecture Pattern & Boundary Map
Same two computation sites, one additional resolved value, fed only into each component's --version composition call — never into its startup-log call.
graph TB
DevScript[xce-build.sh git diff plus untracked files sha256]
NixPkg[flake.nix eval time self.narHash truncated]
MesonOpt[meson dirty_fingerprint option]
VersionCompose[per-component --version composition]
LogCompose[per-component startup log composition unchanged]
DevScript --> MesonOpt
NixPkg --> MesonOpt
MesonOpt --> VersionCompose
MesonOpt -.not consumed.-> LogCompose
Architecture Integration:
- Selected pattern: extend the existing single-resolved-value-pair fan-out with a third, optional value that only one of each component's two existing consumption call sites reads.
- Domain/feature boundaries: computation (2 sites, unchanged locations) stays separated from consumption; the new divergence is which consumption call site (per component) reads the new value, not a new computation or plumbing layer.
- Existing patterns preserved: meson option → C define / Go ldflags / Python generated module, identical to the original feature.
- New components rationale: no new components — every change extends an existing computation site or an existing composition call site with one additional field/argument.
- Steering compliance: no new external dependency; TDD required for every new behavior (per
testing.md).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Dev-loop computation | git diff HEAD + git ls-files --others --exclude-standard, sha256sum (coreutils, already used by xce-build.sh) |
Computes the fingerprint from tracked-modification diffs and untracked-file content | xce-build.sh |
| Nix production computation | self.narHash (Nix flake evaluator, eval-time) |
Content hash of the dirty tree's tracked-file state, truncated the same way | xce.nix, xcecli.nix; empirically confirmed reachable and content-sensitive (see research.md) |
| C build | Meson preprocessor define (existing mechanism, one new define) | Carries dirty_fingerprint into xce_compose_version()'s --version-composition call only |
src/meson.build |
| Go build | -ldflags -X (existing mechanism, one new var) |
Injects main.DirtyFingerprint, read only by each binary's --version/-version handling |
go_build, Makefile |
| Python build | Meson custom_target (existing mechanism, one new field) |
_build_info.py gains a third field, read only by cli.py's click.version_option() |
meson-build.sh |
File Structure Plan
Modified Files
src/meson.options— add adirty_fingerprintstring option (default''), sibling to the existingcommit_id/build_time.src/meson.build— add-DVERSION_DIRTY_FINGERPRINT=to the existingadd_project_arguments()call.src/include/utils.h,src/lib/utils.c— extendxce_compose_version()(or add a sibling composition function used only by--versioncall sites) to optionally append-<dirty_fingerprint>when non-empty; leave the existing signature's startup-log call sites unaffected (they simply don't pass the new field, or call the original unchanged function).src/controller/main.c,src/scheduler/src/main.c,src/controller/testsuite/test.c,src/worker/src/main.c—--version/version-flag handling call sites gain the new field; startup-log call sites are explicitly left unchanged (one-line, clearly-commented divergence — seeresearch.md's Design Decision).src/tools/xce-build.sh— computedirty_fingerprint(git diff + untracked file content, sha256 truncated, empty when clean) and pass it via a new-Ddirty_fingerprint=entry inMESON_OPTS, alongside the existingcommit_id/build_timeentries.src/flake.nix— the existing eval-timeletblock gainsmyDirtyFingerprint(derived fromself.narHash, truncated, empty on a clean tree), computed alongsidemyCommitIdand passed intonixSupport/default.nixas a new argument, the same waymyCommitIdalready is.src/nixSupport/default.nix— accepts and forwardsmyDirtyFingerprintinto eachcallPackage ./xce.nix/./xcecli.nixcall viainherit, alongside the existingmyCommitId.src/nixSupport/xce.nix,src/nixSupport/xcecli.nix— accept the newmyDirtyFingerprintargument and append"-Ddirty_fingerprint=" + myDirtyFingerprintto the existingmesonFlagslist (eval-time, alongside-Dcommit_id=) — not through thepreConfigure/mesonFlagsArraymechanism, which stays reserved forbuild_time.src/tools/go_build— readXCE_DIRTY_FINGERPRINT(empty-safe) and append it to the existing-ldflags -Xconstruction asmain.DirtyFingerprint.src/frontend/apiserver/meson.build,src/frontend/xcecli/meson.build— pass the new env var intogo_build's env, same pattern asXCE_GIT_COMMIT/XCE_BUILD_TIME.src/frontend/Makefile— compute and pass the same-ldflags -X main.DirtyFingerprint=...forbuild-server/build-cli's dev-loop path.src/frontend/apiserver/cmd/main.go— declarevar DirtyFingerprint = "";-versionflag's composed string appends it when non-empty; startup log'slogger.Info(...)call is left unchanged (no new zap field).src/frontend/xcecli/cmd/main.go— declare the same var; the version string passed torouter.Execute(cm, version)(which setsrootCmd.Version) appends it when non-empty.src/xceadmin/meson.build— pass one new positional arg (or env var) tomeson-build.sh.src/xceadmin/meson-build.sh—_build_info.pygains a third field,DIRTY_FINGERPRINT(empty-string fallback, matching the existing per-fieldunknown-vs-empty convention used for the other two fields — see Implementation Notes).src/xceadmin/src/xceadmin/_build_info.py(committed fallback stub) — gainsDIRTY_FINGERPRINT = ""alongside the existing two"unknown"fields.src/xceadmin/src/xceadmin/cli.py—_compose_version()appends the fingerprint when non-empty.
No new files. Every change extends an existing computation site, an existing meson option chain, or an existing composition call site.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1, 1.2, 1.3 | Content-derived indicator present when dirty, deterministic, distinguishes different uncommitted content | xce-build.sh (git diff + untracked files), nix self.narHash |
dirty_fingerprint meson option, per-component --version composition |
Computation → meson → --version composition |
| 1.4 | Excludes ignored files | xce-build.sh's git ls-files --others --exclude-standard (respects .gitignore natively); nix's self.narHash (only ever sees files the flake's git input would include, which already excludes ignored files) |
n/a | n/a |
| 2.1 | Clean tree shows commit id alone | All 6 --version composition call sites |
dirty_fingerprint empty string → no suffix appended |
n/a |
| 3.1, 3.2 | --version-only scope; startup log unchanged |
Each daemon's two separate composition call sites | Startup-log call site never receives dirty_fingerprint |
n/a |
| 4.1 | Applies to all 6 components | All 6 consumption sites | Same as original feature's Requirement 1.1/1.2 fan-out, one new field | n/a |
| 5.1, 5.2 | Graceful degradation when content can't be determined | Every --version composition call site (falls back to commit_id as-is when dirty_fingerprint is empty) |
dirty_fingerprint empty on computation failure; commit_id's own pre-existing, untouched -dirty suffix supplies the "still indicate modified" signal for free |
n/a |
Components and Interfaces
C Daemons (controller, scheduler, worker)
Version composition, --version-only extension
| Field | Detail |
|---|---|
| Intent | Append a content-derived fingerprint to --version's commit id when the tree is dirty, without touching the startup log's version fields |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 3.1, 3.2, 4.1 |
Responsibilities & Constraints
commit_idand its existing-dirtysuffix logic (from the originalbuild-time-versioningfeature) are not modified — identical computation, identical value, at both computation sites.xce_compose_version()(or a new sibling function reusing its internals) gains an optionaldirty_fingerprintparameter, consumed only by the--versioncall path: whendirty_fingerprintis non-empty, the composition strips a trailing-dirtyfromcommit_id(if present) and appends-<dirty_fingerprint>instead, producing<commit_id>-<hash>; whendirty_fingerprintis empty, the composition usescommit_idcompletely as-is — which already correctly shows either the bare hash (clean tree) or<hash>-dirty(tree is dirty but the fingerprint computation failed, Requirement 5.2's fallback, satisfied by the pre-existing mechanism with no new sentinel value needed).- Each
main.c's--version/argp_program_versioncall site passes the new field; the corresponding startup-log INFO call site is not touched at all — it keeps calling the original, unmodified composition path exactly as the original feature shipped it, so it never even referencesdirty_fingerprint. - Worker's hand-added
--versioncase (from the original feature) gets the same treatment as controller/scheduler.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Implementation Notes
- Integration: extends
xce_compose_version()— check during implementation whether adding a parameter to the existing function is cleaner than adding a second function; either is acceptable as long as the startup-log call site's behavior is provably unchanged (byte-for-byte) by a regression test. - Validation: (1) a build with
dirty_fingerprintempty and a clean tree must produce--versionoutput byte-identical to the original feature's already-shipped clean-tree behavior (<commit_id>alone); (2) a build withdirty_fingerprintempty and a dirty tree (fingerprint computation failed) must produce--versionoutput byte-identical to the original feature's already-shipped dirty-tree behavior (<commit_id>-dirty) — Requirement 5.2, satisfied without new code beyond "don't touch commit_id when dirty_fingerprint is empty"; (3) a build with a real, non-emptydirty_fingerprintmust produce<bare-commit-hash>-<dirty_fingerprint>, with the-dirtysuffix stripped, not doubled. - Risks: (1) accidentally threading the new field into the startup-log call site would silently violate Requirement 3.2 — must be caught by a test asserting the startup log line is unchanged from the original feature's already-shipped format; (2) the
-dirty-stripping logic must anchor to the literal trailing suffix (not a blind substring removal) — covered by a dedicated unit test.
Go Binaries (apiserver, xcecli)
ldflags fingerprint injection, --version/-version-only
| Field | Detail |
|---|---|
| Intent | Same --version-only extension for the Go toolchain |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 3.1, 3.2, 4.1 |
Responsibilities & Constraints
go_buildreadsXCE_DIRTY_FINGERPRINT(empty-safe) and appends-X main.DirtyFingerprint=$vto its existing-ldflagsconstruction.GitCommititself (and whatever-dirtysuffix it already carries) is completely untouched.apiserver/cmd/main.go: the-versionflag's composition applies the same strip-and-append rule as the C daemons — ifDirtyFingerprintis non-empty, strip a trailing-dirtyfromGitCommitand append-DirtyFingerprint; if empty, printGitCommitas-is (already correctly showing-dirtyor not). The existinglogger.Info("********** Initialize Logger done!")structured-field call is left completely unchanged (no new zap field,GitCommitpassed exactly as before) — this is the explicit Requirement 3.2 boundary for this component.xcecli/cmd/main.go: the version string passed intorouter.Execute(cm, version)applies the same strip-and-append rule; xcecli has no startup log (already established as out of scope by the original feature), so there is no divergence to manage here.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Implementation Notes
- Integration: reuses
go_build's existing env-var pattern, one more variable. - Validation: apiserver's startup log must be provably unchanged (byte-for-byte against the original feature's shipped format) even when
DirtyFingerprintis non-empty. - Risks: none identified beyond the same one already flagged for C — accidental leakage of the new field into the startup-log call site.
Python (xceadmin)
Generated build-info module, third field
| Field | Detail |
|---|---|
| Intent | Same --version-only extension for xceadmin, which has no startup log at all (one-shot CLI, already out of scope per the original feature) |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 4.1 |
Responsibilities & Constraints
meson-build.shwritesDIRTY_FINGERPRINT(empty-string fallback) into_build_info.pyalongside the existingGIT_COMMIT/BUILD_TIMEfields.GIT_COMMITitself (which already carries a-dirtysuffix on a dirty tree, inherited from the sharedcommit_idmeson option) is completely untouched.- The committed fallback stub (
src/xceadmin/src/xceadmin/_build_info.py) gainsDIRTY_FINGERPRINT = ""— an editable install (pip install -e .) shows no fingerprint suffix, consistent with the stub's existing all-unknown/empty convention. cli.py's_compose_version()applies the same strip-and-append rule as the other components: ifDIRTY_FINGERPRINTis non-empty, strip a trailing-dirtyfromGIT_COMMITand append-DIRTY_FINGERPRINT; if empty, useGIT_COMMITas-is.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Implementation Notes
- Integration: one more field in the same generated-module mechanism the original feature already built (task 5.1/5.2).
- Validation: editable install must still show no fingerprint (empty field, no import error); wheel install (built dirty) must show a real, non-empty fingerprint.
- Risks: none identified — xceadmin already has no startup log to accidentally leak into.
Build Orchestration
Dev-loop fingerprint computation
| Field | Detail |
|---|---|
| Intent | Compute a content-based fingerprint of the uncommitted tree, with zero working-tree mutation |
| Requirements | 1.1, 1.2, 1.3, 1.4, 5.1, 5.2 |
Responsibilities & Constraints
commit_idand its existing-dirtysuffix (xce-build.shtask 2.1 of the original feature) are not touched by this computation — this task only adds a new, independentdirty_fingerprintcomputation alongside it.- Computes
dirty_fingerprintas one of two states: empty when the tree is clean, or when the tree is dirty but the hash computation itself fails (in the latter case,commit_id's own pre-existing, untouched-dirtysuffix already signals "modified" once--versionfalls back to using it as-is — Requirement 5.2, satisfied with no new sentinel value); a real hash (sha256sumtruncated to a short hex prefix) over the concatenation ofgit diff HEAD's output plus the raw content of each filegit ls-files --others --exclude-standardlists, in stable sorted order, when the tree is dirty and the computation succeeds.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Implementation Notes
- Integration: added to the same
xce-build.shblock that already computescommit_id/build_time(original feature's task 2.1), no restructuring. - Validation: the exact scenario this feature exists for — edit a file, build, note the fingerprint; edit further, rebuild, confirm a different fingerprint; revert to the first edit's exact content, rebuild, confirm the fingerprint matches the first build's again.
- Risks:
git diff HEADoutput size for a very large uncommitted change set is unbounded in principle — acceptable for this codebase's realistic dev-loop usage (source-code-sized diffs), not a concern requiring truncation of the input, only the output hash.
Nix production fingerprint computation
| Field | Detail |
|---|---|
| Intent | Same capability for the Nix production path, within its sandboxed, no-git constraint |
| Requirements | 1.1, 1.2, 1.3, 5.1 |
Responsibilities & Constraints
myCommitIdand its existingself.dirtyShortRev-derived-dirtysuffix (original feature) are not touched by this computation.myDirtyFingerprintis derived fromself.narHashat eval time, inflake.nix's existingletblock, alongsidemyCommitId— not via apreConfigurebuild-phase hook. Unlikebuild_time(which needed a build-phase hook specifically becausebuiltins.currentTimebreaks pure-eval),self.narHashis a legitimate flake self-introspection field with no such trap — empirically confirmed reachable and stable under plainnix build/nix eval(no--impure) inresearch.md. It is passed intoxce.nix/xcecli.nix(vianixSupport/default.nix) as a new function argument, mirroringmyCommitId's existing parameter, and appended to each derivation'smesonFlagslist directly ("-Ddirty_fingerprint=" + myDirtyFingerprint), the exact same way-Dcommit_id=already is — not throughmesonFlagsArray/preConfigure, which stays reserved for genuinely build-phase-only values likebuild_time.- Resolves to the same two states as the dev-loop path: empty when the tree is clean, checked the same way
myCommitIdalready branches onhasAttr "shortRev"(a clean tree only hasself.shortRev, notself.dirtyShortRev); otherwise a real hash, truncated to match the dev-loop path's format. - Known, accepted limitation (see
research.md): does not reflect brand-new untracked files unless staged. Must be documented as a code comment at thislet-binding, not silently left as a surprise.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Implementation Notes
- Integration: extends
flake.nix's existing eval-timemyCommitIdcomputation with one sibling value, passed through toxce.nix/xcecli.nixas one moremesonFlagsentry — no new build-phase mechanism, no change to the existingbuild_timepreConfigurehook. - Validation:
nix build .#xcecore_debug(no--impure, the documented canonical command) must still succeed and produce a binary whose--versionfingerprint changes when a tracked file in the flake's source is edited and the flake is re-evaluated. - Risks: low —
research.mdalready empirically confirmedself.narHashis reachable and content-sensitive without--impure; the implementer should still re-confirm live as part of RED-phase validation rather than merely trusting this document, since the earlierbuild_time/builtins.currentTimemistake in the original feature came from exactly that kind of unverified assumption.
Testing Strategy
Unit Tests
- C:
test_utils.ccases assertingxce_compose_version()'s--version-path composition: (1)dirty_fingerprintnon-empty +commit_idending in-dirty→ strips the suffix, appends the fingerprint (<hash>-<fingerprint>, not<hash>-dirty-<fingerprint>); (2)dirty_fingerprintempty + cleancommit_id→<hash>alone, unchanged; (3)dirty_fingerprintempty +commit_idending in-dirty(fingerprint computation failed, tree still dirty) →<hash>-dirty, Requirement 5.2's fallback, produced by simply not touchingcommit_id— no special-casing needed in the composition function beyond the empty check. - Shell: a unit-level check of
xce-build.sh's fingerprint-computation logic confirms it emits a real hash when the tree is dirty and the computation succeeds, and confirms it emits empty (not a fabricated value) both when the tree is clean and when the hash-computation step is made to fail (simulated) on a dirty tree — the latter case relying oncommit_id's own separate, untouched-dirtycomputation to still convey "modified" once composed. - Go: a test asserting
-version/--versionoutput includesDirtyFingerprintwhen set, and that apiserver's startup-log-composition path is unaffected by its presence. - Python: a
pytestcase assertingxceadmin --versionincludes the fingerprint field when_build_info.DIRTY_FINGERPRINTis non-empty, and omits any suffix when empty (both the committed-stub and generated-module cases, mirroring the original feature's task 5.2 test pattern).
Integration Tests
xce-build.sh-driven dev-loop build (any C daemon): edit a tracked file, build, capture--version's fingerprint; edit again with different content, rebuild, confirm the fingerprint differs; revert to the first edit's exact content, rebuild, confirm the fingerprint matches the first capture exactly. This is the direct regression test for Requirement 1's core capability.- Same daemon, confirm the startup log line is untouched (byte-identical to the original
build-time-versioningfeature's already-shipped format) regardless of whether the tree is dirty. - A clean-tree build (
git stashthe dev tree, or run in a freshly-cloned worktree) confirms--versionshows the commit id alone, no suffix — the direct regression test for Requirement 2.
E2E / Consistency
- For the Nix path: confirm
nix build .#xcecore_debug(no--impure) succeeds and the resulting binary's--versionfingerprint changes when a tracked source file is edited before evaluation — the direct regression test for theself.narHash-based mechanism actually working end-to-end, not just at thenix evalprobe level this design's research was based on.