Repository navigation
REQ-CONFIG-005: validate investmentGoodsPerCapitalUnit instead of silently dropping it - #502
Conversation
…ently dropping it validateDefinitionPack() never inspected RecipeDefinition.investmentGoodsPerCapitalUnit. resolveCapitalGoodsPerCapitalUnit() was the first consumer on the genesis path and it filters out any entry that is not finite and > 0, so a NaN, Infinity, zero or negative coefficient produced an empty result — indistinguishable from a recipe that genuinely declares no investment good. Because buildInitialWorld() and reconcileGenesisStocks() both resolve through that same filter, the invalid configuration reconciled cleanly: two sides sharing one reinterpretation agree. The filter had no key-side check either, so a coefficient naming an undeclared good was dropped by the same silent path. validateDefinitionPack() now rejects, before any world construction, a coefficient that is non-finite or <= 0 and a coefficient keyed by a good the pack does not declare, with a diagnostic naming the recipe and the good. An empty map stays valid: it is a real "no investment good" declaration, and the baseline pack relies on it. The resolver's filter is kept as defence-in-depth rather than promoted to an assertion — validation is now authoritative on the genesis path, but the resolver is also reachable from a registry assembled without it, and both the emitting and reconciling side call it, so loosening one side only would let them disagree. Its doc comment now says so. Files a two-part researcher feedback entry: the specification states no per-coefficient bound for this field (the strictly-positive rule is an inference from the divide in 05 ...:463, the same argument HANDOFF-REPAIR-015 used for inputsPerBatch), and its only prose annotation, "at least one good for capital-forming recipes", is unmeasurable because "capital-forming recipe" is defined nowhere. Closes #465 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updates the existing REQ-CONFIG-005 row rather than adding a second one, per AUTHOR_RUNBOOK.md section 7. STATUS stays IMPLEMENTED; ISSUE/PR move to the contributing pair for this slice and EVIDENCE now names #76, #88 and #502. MERGE_COMMIT is cleared for backfill_merge_commits.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AUTHOR note: the earlier
|
ACCEPTOR verdict: ACCEPT — head
|
| Check | Conclusion | Run |
|---|---|---|
policy-guard |
SUCCESS (06:48:17Z) |
34938587190 |
typescript |
SUCCESS (06:48:36Z) |
34938587190 |
build-and-test |
SUCCESS (06:48:43Z) |
34938587190 |
mergeability |
SUCCESS |
Mergeability run 34938513188 (push) |
write-mergeability-status |
SUCCESS ×2 |
34938513188, 34938518688 |
Nothing is pending, skipped, neutral or unknown.
The earlier policy-guard FAILURE at this same head was checked rather than taken on the
author's word: gh api .../actions/runs/34938518679 and .../34938587190 both report
head_sha: 0ff4fc3eb13c499196b2b2db2000566181bcf0ca, run_attempt: 1, created 06:47:14Z and
06:48:07Z respectively. The success is the later run at the same head, so it is the current
result for that check name, which is also what the rollup resolves to. No commit separates them —
git log bd74d83..0ff4fc3 is two commits, e00272f then 0ff4fc3, both before either run. The
failure is recorded here as failed for the run that produced it; it is not promoted.
mergeable: MERGEABLE, mergeStateStatus: CLEAN, and git merge-base --is-ancestor origin/master 0ff4fc3 succeeds — the branch merges cleanly and is current with the base, which are the two
separate conditions.
Independent re-execution at 0ff4fc3 (detached checkout of the head), both runtimes:
| Command | Outcome | Evidence |
|---|---|---|
npm ci |
passed |
clean install |
npm run typecheck |
passed |
tsc --noEmit clean |
npm test |
passed |
Test Files 46 passed (46); Tests 671 passed (671) |
npm run build |
passed |
dist/canonical.js emitted |
dotnet restore |
passed |
both projects |
dotnet build --configuration Release --no-restore |
passed |
0 errors |
dotnet test --configuration Release --no-build |
passed |
Failed: 0, Passed: 45, Total: 45 — REQ-MIGRATION-003 maintained |
python scripts/implementation_status.py --check |
passed |
matches 30 ledger rows over 49 registry rows |
2. Every acceptance criterion is met, with observed evidence
- Invalid values rejected before world construction — met.
src/config/validation.ts:229-236
rejects a non-finite or<= 0coefficient insidevalidateDefinitionPack(). Proven at two
levels: five cases insrc/config/validation.test.ts:876-887, and the same five through
buildInitialWorld()insrc/simulation/genesisInvestmentCoefficients.test.ts:47-64. The
second is what makes "before any world construction" evidence rather than assertion. - Unknown good key rejected — met.
validation.ts:222-226checksdeclaredGoodKeysfirst, so
a reference failure reports as a reference failure (section 19 step 1 order). Tested at both
levels (validation.test.ts:889-894,genesisInvestmentCoefficients.test.ts:66-74). - The finding's reproduction throws — met.
genesisInvestmentCoefficients.test.tsruns exactly
the stated reproduction: baseline pack,recipe:tools-craft.investmentGoodsPerCapitalUnitset to
the invalid value,buildInitialWorld()on the otherwise-valid baseline scenario and default
config. It throws instead of emitting good-less reconcilingUNCONVERTEDcapital. - The valid baseline pack still passes — met. Asserted directly (
accepts the unmodified baseline definition pack,still accepts an empty investment map) and indirectly by the whole
suite: 671/671 pass at this head in my own run. - Negative controls confirmed sensitive — met, and re-measured by this run, not accepted on
report. I restoredsrc/config/validation.tsto itsbd74d83content with the new tests in
place and ran both files:Tests 12 failed | 54 passed (66), the twelve failures being exactly
the twelve new rejection cases, eachAssertionError: expected [Function] to throw an error. The
four new acceptance tests passed under the revert, as they should. The working tree was restored
to0ff4fc3afterwards (git status --porcelainempty); no commit was made and nothing was
pushed to the branch. - Dated two-part researcher feedback — met.
docs/spec/FEEDBACK_TO_RESEARCHER.mdgains
## 2026-09-15 — REQ-CONFIG-005 — …in the file's Observed/Problem/Proposal/Impact shape,
appended with nothing rewritten (+64 −0). Part 1 is the unstated per-coefficient bound; part 2
is the unmeasurable "at least one good for capital-forming recipes".
The claimed requirement identifier is implemented, not merely mentioned. REQ-CONFIG-005 in
REQUIREMENTS_REGISTRY.csv:16 is "Invalid references, non-finite values and out-of-range
configuration fail fast with useful diagnostics; do not silently coerce" — an invariant. The diff
adds exactly that: a reference check, a finite/range check, and a diagnostic naming recipe, field,
key and value. The ledger row is updated in place (ISSUE 465, PR 502, MERGE_COMMIT empty for
backfill_merge_commits.py, EVIDENCE naming #76, #88 and #502), REQ-CONFIG-003's row is
untouched, no second REQ-CONFIG-005 row was added, and IMPLEMENTATION_STATUS.md is regenerated
rather than hand-edited — the --check run above is the proof.
3. The diff is confined to the declared scope
Seven paths, every one inside the Issue's Scope: src/config/validation.ts (the required rule),
src/config/validation.test.ts and src/simulation/genesisInvestmentCoefficients.test.ts (criteria
1-5), src/domain/definitionRegistry.ts (doc comment only, +12 −0, no behavior change — the
Decision the Scope required, also recorded as a comment on #465),
docs/spec/FEEDBACK_TO_RESEARCHER.md, docs/spec/implementation_status.csv and its generated
docs/spec/IMPLEMENTATION_STATUS.md. Nothing touches docs/spec/mirror/**, and nothing touches
.github/workflows/**, AGENTS.md or docs/zendev/** — so no policy path is mixed with product
code and no automated authority is widened by this merge. Non-goals are respected: opening
quantities, recipes, the baseline pack and reconciliation keying are unchanged.
4. No invariant and no test was weakened
Both test files are additions only — validation.test.ts +59 −0, genesisInvestmentCoefficients.test.ts
+83 −0 (new) — and no other test file is in the diff. No deletion, no skip, no loosened
assertion; the suite goes up, and the discovered count rises from 659 to 671. No conservation
invariant is relaxed: the change adds a refusal, it does not widen what passes. The one behavioral
risk is the opposite direction — the new key-existence check can reject a pack that previously
loaded — and it is bounded by the fact that resolveCapitalGoodsPerCapitalUnit() already discarded
every value now rejected, so no pack that is valid today changes outcome. The full suite, baseline
pack included, is the evidence.
On the highest-risk area the author nominated: the key comparison is string-to-string between
Object.keys(definitionPack.goods) and the coefficient map's keys, and both are canonical
good:* keys in the baseline pack and in packWithTools. The noted asymmetry — inputsPerBatch
carries no equivalent key check — is real but correctly left alone: widening it is a different
requirement, and inventing it here would be the scope creep this runbook refuses. It is a fair
question to route to a follow-up Issue rather than a defect in this change.
5. No secret, credential, or personal data is present
policy-guard is green at this head, and reading the diff myself found no token, credential or
local machine path — the only paths are repository-relative.
6. The handoff record is complete
All nine elements are present and, more to the point, honest: the checks table separates what was
measured from what was not_run and says why, the strictly-positive bound is labelled an inference
rather than a quoted rule, and the empty-map reading is labelled an assumption with the
specification tension stated. Both are routed to the researcher in this same pull request, and
neither blocks: the adopted reading rejects only values the resolver already discarded.
Merging
No standing CHANGES_REQUESTED exists on this pull request from this identity or any other, so the
merge is executable and this is not a verdict I cannot carry out. Squash-merging and deleting the
branch.
Updates the single existing REQ-CONFIG-005 row rather than appending a second, as section 7 of the AUTHOR runbook requires: PR repointed to #510, MERGE_COMMIT cleared because it cannot be known from inside the pull request that carries the row, ISSUE set to the issue this slice closes, and EVIDENCE extended to name every contributing pull request (#76, #88, #502, #510). STATUS stays IMPLEMENTED: the repair strictly increases coverage of the invariant and asserts nothing new. The evidence cell says outright what the row does not cover - outputGoodId is still unchecked by validateDefinitionPack() - and names Issue #509, so the record does not read as total coverage. Refs #508 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t declare (#510) * REQ-CONFIG-005: reject inputsPerBatch keys the DefinitionPack does not declare Handoff/03 section 21 fails configuration validation fast on unknown Good IDs, and section 19 step 1 runs that validation before genesis constructs anything. validateDefinitionPack() enforced that membership rule for the good-keyed map investmentGoodsPerCapitalUnit but not for the sibling inputsPerBatch, over the same declaredGoodKeys set in the same function. An undeclared input Good whose coefficient was finite and strictly positive therefore passed step 1 and reached world construction. inputsPerBatch keys are now checked against declaredGoodKeys before the coefficient bound, with a diagnostic naming the recipe and the unknown Good, matching the sibling check's wording. An empty map and a declared good with a strictly positive coefficient remain valid. Regressions: three direct validateDefinitionPack() cases plus a genesis-level file running the reproduction end to end through buildInitialWorld() on the baseline scenario. The shared test fixture minimalPack() declared no goods at all while minimalRecipe() referenced good-2, so it now declares the goods its recipe names; that makes the pack well-formed under both reference checks instead of accidentally exercising them. Negative control: with the membership check removed and the tests in place, exactly the three undeclared-Good rejections fail and the other 70 tests in those files pass; restoring it returns 73/73. Refs #508 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ledger: record PR #510 against REQ-CONFIG-005 Updates the single existing REQ-CONFIG-005 row rather than appending a second, as section 7 of the AUTHOR runbook requires: PR repointed to #510, MERGE_COMMIT cleared because it cannot be known from inside the pull request that carries the row, ISSUE set to the issue this slice closes, and EVIDENCE extended to name every contributing pull request (#76, #88, #502, #510). STATUS stays IMPLEMENTED: the repair strictly increases coverage of the invariant and asserts nothing new. The evidence cell says outright what the row does not cover - outputGoodId is still unchecked by validateDefinitionPack() - and names Issue #509, so the record does not read as total coverage. Refs #508 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Updates the single REQ-CONFIG-005 row rather than adding a second one: PR becomes #512, ISSUE becomes #509 to stay paired with it, MERGE_COMMIT is cleared for scripts/backfill_merge_commits.py, and EVIDENCE names every contributing pull request (#76, #88, #502, #510 and now #512). STATUS stays IMPLEMENTED. The change strictly increases coverage and asserts nothing new; the gap the previous evidence named as unmeasured is now measured and closed. IMPLEMENTATION_STATUS.md is regenerated by scripts/implementation_status.py, not hand-edited. Refs #509 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not declare (#512) * REQ-CONFIG-005: reject a recipe outputGoodId the DefinitionPack does not declare `validateDefinitionPack()` built `declaredGoodKeys` once per pack and consulted it for both good-keyed recipe maps — `investmentGoodsPerCapitalUnit` (#502) and `inputsPerBatch` (#510) — but never for the recipe's one scalar Good reference, `outputGoodId`. Handoff/03 section 21 fails configuration validation fast on "unknown Good/Recipe/Event/Metric IDs" and section 24 invariant 3 requires every ID to be reference-valid, so a recipe producing a Good the pack does not declare should not survive world-genesis step 1. Nothing downstream rejected it either. Unlike an undeclared investment good, which `resolveCapitalGoodsPerCapitalUnit()` at least drops, `outputGoodId` has no canonical reader at all — production is M4 — so outside tests it appears only in the type declaration and the baseline fixtures. The invalid reference reached the constructed world untouched. This is a latent fail-fast gap rather than an observable production defect, and the repair closes it at the authoritative validator. The check runs before the `outputPerBatch` bound and throws a diagnostic naming the recipe and the unknown Good, matching the two sibling reference checks. Negative control: with the guard removed and the tests in place, exactly the four rejection regressions fail and the other 68 tests in those files pass (4 failed | 68 passed of 72); restoring the guard returns 72/72. Refs #509 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ledger: record REQ-CONFIG-005 outputGoodId evidence for PR #512 Updates the single REQ-CONFIG-005 row rather than adding a second one: PR becomes #512, ISSUE becomes #509 to stay paired with it, MERGE_COMMIT is cleared for scripts/backfill_merge_commits.py, and EVIDENCE names every contributing pull request (#76, #88, #502, #510 and now #512). STATUS stays IMPLEMENTED. The change strictly increases coverage and asserts nothing new; the gap the previous evidence named as unmeasured is now measured and closed. IMPLEMENTATION_STATUS.md is regenerated by scripts/implementation_status.py, not hand-edited. Refs #509 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #465
Achieved outcome
An invalid
RecipeDefinition.investmentGoodsPerCapitalUnitcoefficient now fails configurationvalidation before any world construction, instead of being silently filtered out downstream and
becoming indistinguishable from a recipe that genuinely declares no investment good.
validateDefinitionPack()never inspected the field, soresolveCapitalGoodsPerCapitalUnit()(
src/domain/definitionRegistry.ts) was the first and only consumer on the genesis path — and itdrops any entry that is not finite and
> 0. ANaN,Infinity, zero or negative coefficienttherefore yielded an empty result,
buildInitialWorld()took the "no investment good" path andemitted good-less
UNCONVERTEDcapital, and becausereconcileGenesisStocks()calls the sameresolver on the actual side, the invalid configuration reconciled cleanly. That is why no existing
conservation test caught it: two sides sharing one reinterpretation agree with each other. The
filter had no key-side check either, so a coefficient naming a good the pack does not declare was
dropped by the same silent path rather than rejected. Both cases are now rejected with a diagnostic
naming the recipe and the good; an empty map stays valid, because it is a real "no investment good"
declaration that the baseline pack relies on.
Tested revision
0ff4fc3eb13c499196b2b2db2000566181bcf0ca— the branch head. Every check in the table below ranagainst that revision, after the ledger commit, not against the earlier code-only commit.
Changed artifacts
Every path in
git diff --name-only origin/master...HEAD:src/config/validation.ts—validateDefinitionPack()now builds the set of declared good keysonce per pack and, for each recipe, rejects an
investmentGoodsPerCapitalUnitentry whose key isnot a declared good and an entry whose value is non-finite or
<= 0. The key check runs first, soa reference failure is reported as a reference failure (section 19 step 1 orders references before
finite values). The function's doc comment gains the new rule.
src/domain/definitionRegistry.ts— doc comment only, no behavior change. Records the Scopedecision below: the resolver's filter stays as defence-in-depth and is deliberately not an
assertion.
src/config/validation.test.ts— a newinvestmentGoodsPerCapitalUnit (REQ-CONFIG-005)describeblock: five rejection cases (
NaN,+Infinity,-Infinity, zero, negative), one unknown-good-keyrejection, and two acceptance cases (a strictly positive coefficient on a declared good; an empty
map). It needs a pack that actually declares
good:tools, since the file's existingminimalPackhelper carries
goods: {}.src/simulation/genesisInvestmentCoefficients.test.ts— new file. The finding's ownreproduction, end to end: the baseline pack with
recipe:tools-craft's coefficient made invalid,then
buildInitialWorld()on the otherwise-valid baseline scenario and default config. Fiveinvalid values plus the unknown-good key must throw; the unmodified baseline pack and an empty
map must not.
docs/spec/FEEDBACK_TO_RESEARCHER.md— the dated two-part entry required by the Issue (appended,nothing rewritten).
docs/spec/implementation_status.csv— the existingREQ-CONFIG-005row updated in place.docs/spec/IMPLEMENTATION_STATUS.md— regenerated bypython scripts/implementation_status.py;not hand-edited.
No change to
docs/spec/mirror/**.Acceptance criteria
NaN,+Infinity,-Infinity,0or a negative coefficient is rejected,with a diagnostic naming the recipe and the good, before any world construction. Five cases in
validation.test.tsassert on the thrown message(
RecipeDefinition "recipe-1": investmentGoodsPerCapitalUnit["good:tools"] … strictly positive);five more in
genesisInvestmentCoefficients.test.tsassert the same throughbuildInitialWorld(),which is what makes "before any world construction" load-bearing rather than assumed.
and the unknown good. One test at each level, asserting on
investmentGoodsPerCapitalUnit["good:unobtainium"] references a Good the DefinitionPack does not declare.UNCONVERTEDcapital.
genesisInvestmentCoefficients.test.tsruns exactly the stated reproduction —baseline pack,
recipe:tools-craft.investmentGoodsPerCapitalUnitset to{ "good:tools": NaN },buildInitialWorld()on the otherwise-valid baseline scenario/config.baselineDefinitionPack.ts:121, still passes. Directly asserted (accepts the unmodified baseline definition pack;still accepts an empty investment map), and indirectly by the wholesuite: the baseline pack is built by many existing tests and all 671 pass.
src/config/validation.tswas restored to itsmastercontent with the new tests in place. Result: 12 failed | 54 passed (66). The twelvefailures are exactly the twelve new rejection tests (six in each file), each
AssertionError: expected [Function] to throw an error. The four new acceptance tests passedunder the revert, as they should — they assert the repair does not over-reject. Restoring the
repair returns 66/66.
FEEDBACK_TO_RESEARCHER.mdcarries the dated two-part entry referencingREQ-CONFIG-005. Entry## 2026-09-15 — REQ-CONFIG-005 — …, in the file's documentedObserved/Problem/Proposal/Impact shape.
Checks
npm cipassednpm run typecheckpassedtsc --noEmit, cleannpm testpassednpm run buildpassedvite build,dist/canonical.jsemittedpython scripts/implementation_status.py --checkpassedpasseddotnet restorepasseddotnet build --configuration Release --no-restorepasseddotnet test --configuration Release --no-buildpassedREQ-MIGRATION-003maintained)All outcomes above are measured, at
0ff4fc3. Nothing is promoted.Not checked
policy-guard,scope-guard,status_lint,mergeability,machine-pr-guard):not_runlocally — they are forge-side and run on this pullrequest.
scope-guardis the one most likely to bite; the Changed artifacts section above listsevery path from
git diff --name-only origin/master...HEADverbatim, which is what it comparesagainst. Residual risk: low, and self-correcting — a body edit re-runs it without a new commit.
existing suite. The repair only rejects values that never reached production code, because the
resolver already discarded them.
not_run— none exists. See the assumption below.Assumptions and unknowns
unknown, and it is why the Issue mandates researcher feedback.
06 - Handoff/03 …:490(section16A) enumerates recipe validation field by field and omits this field;
06 - Handoff/05 …:62-78annotates every sibling numeric field with an explicit bound and annotates this one with prose
only. The inference is strong —
05 …:463divides by the coefficient, so zero is adivide-by-zero and negative makes the minimum meaningless, the same argument
HANDOFF-REPAIR-015used to fixinputsPerBatch— but it is an inference. Filed as part 1 ofthe feedback entry.
good for capital-forming recipes", is unmeasurable: "capital-forming recipe" is defined nowhere,
and the baseline's own
investmentGoodsPerCapitalUnit: {}must stay valid (criterion 4). Filedas part 2 of the feedback entry. If the researcher answers that a predicate exists, the missing
check is a follow-up, not a regression.
resolveCapitalGoodsPerCapitalUnit()already discarded silently. No pack that is valid todaychanges outcome — the whole suite passing, baseline pack included, is the evidence.
defence-in-depth and does not become an assertion. Validation is authoritative on the genesis
path, so the filter can no longer discard anything there; but the resolver is reachable from a
registry assembled without
validateDefinitionPack()(src/domain/definitionRegistry.test.tsdoes exactly that), and the emitting and reconciling sides both call it — loosening one side only
is precisely the drift the shared resolver exists to prevent. Raising there would also move the
diagnostic away from the validator that can name the offending pack. Not recorded as an ADR: it
is cheap to reverse, being one filter in one function.
Highest-risk area for review
src/config/validation.ts, the key-existence check specifically. It is the one part that can rejecta pack that previously loaded, because it consults
definitionPack.goods— state outside the recipebeing validated. Two things worth a reviewer's eye: (a)
goodsis keyed byGoodIdand thecoefficient map is
Readonly<Record<GoodId, number>>, so the comparison is string-to-string atruntime and correct only as long as both are keyed by the same canonical good key — the baseline
pack's
"good:tools"on both sides is the evidence, andpackWithToolsin the tests is theisolated case; (b) the sibling
inputsPerBatchvalidation immediately above has no suchkey check. That asymmetry is deliberate — acceptance criterion 2 scopes the key check to this field,
and widening it to
inputsPerBatchwould be scope creep on a different requirement — but it is thekind of inconsistency worth a second opinion. I have not filed it as an Issue because I could not
establish from the slice I am permitted to read whether
inputsPerBatchkeys are constrained todeclared goods anywhere; a reviewer who knows should say so.
Remaining gate
None mandatory. Every acceptance criterion is met and evidenced, both suites are green at the tested
revision, the ledger row is updated in place (one row per identifier;
REQ-CONFIG-003's rowuntouched;
MERGE_COMMITleft empty forscripts/backfill_merge_commits.py), and the researcherfeedback travels in this pull request.
Two non-blocking follow-ups, both already routed and neither a gate on this merge: the two
specification questions above are in
FEEDBACK_TO_RESEARCHER.mdawaiting the researcher, and theinputsPerBatchkey-check asymmetry noted under Highest-risk area is an open question for review,not discovered work I am withholding.
🤖 Generated with Claude Code