diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 0685a8c..bf9f5ad 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -38,7 +38,43 @@ jobs: - name: Detect TS/dist-relevant changes id: changed + env: + # Single source of truth for "this change can affect ts/dist". Must stay a superset of + # the `on.push.paths` list above — the step below asserts exactly that, because the two + # drifting apart is what this comment block keeps recording. + TS_PATHS: '^(package(-lock)?\.json|ts/|ontology/|scripts/|\.github/workflows/ts-ci\.yml)' run: | + # The PR detector must not be NARROWER than the workflow's own push filter. It was: + # push declared ontology/** and scripts/** relevant, the detector did not, and the + # derivation is real — + # ontology/kko/kko-2.10.n3 -> scripts/gen-kko.mjs -> ts/src/kko-data.ts + # ontology/effect-request/*.json -> scripts/gen-effect-request.mjs -> ts/src/* + # ontology/semantic-action/*.json -> scripts/gen-semantic-action.mjs -> ts/src/semantic-action-data.ts + # -> ts/dist + # so a re-vendor touching only ontology/ took the "trivially satisfied" branch and + # build-and-verify-dist — a REQUIRED check on main — went green having built nothing. + # The kko-provenance job below covers the FIRST of those three chains, but it is not a + # required check, and nothing covers the other two. + # + # Derive the push filter from this file rather than restating it, so widening one and + # forgetting the other fails here instead of silently skipping a build. + python3 - <<'PY' + import os, re, sys, pathlib + wf = pathlib.Path('.github/workflows/ts-ci.yml').read_text().split('\n') + start = next(i for i, l in enumerate(wf) if l.startswith(' push:')) + end = next(i for i, l in enumerate(wf[start + 1:], start + 1) if re.match(r'^ \S', l)) + declared = re.findall(r'^\s*-\s*"([^"]+)"', '\n'.join(wf[start:end]), re.M) + if not declared: + sys.exit('::error::could not read on.push.paths from ts-ci.yml — this guard has gone blind') + rx = re.compile(os.environ['TS_PATHS']) + # One representative path per declared family; a glob stands for a file beneath it. + bad = [p for p in declared if not rx.match(p.replace('**', 'x').replace('*', 'x'))] + if bad: + sys.exit(f'::error::on.push.paths declares {bad} relevant, but the PR change-detector ' + f'regex does not match them. A PR touching only those paths would report ' + f'build-and-verify-dist green without building anything.') + print(f'detector covers all {len(declared)} declared push paths: {declared}') + PY if [ "${{ github.event_name }}" != "pull_request" ]; then # push is already path-filtered to TS-relevant paths — always build. echo "ts=true" >> "$GITHUB_OUTPUT" @@ -53,8 +89,7 @@ jobs: # defines this gate was the one change the gate never ran on. This PR is that # shape: before this line was widened, the commit adding the Test step below had # it reported as `skipped`. - if git diff --name-only "$base" "$head" \ - | grep -qE '^(package(-lock)?\.json|ts/|\.github/workflows/ts-ci\.yml)'; then + if git diff --name-only "$base" "$head" | grep -qE "$TS_PATHS"; then echo "ts=true" >> "$GITHUB_OUTPUT" else echo "ts=false" >> "$GITHUB_OUTPUT" @@ -99,7 +134,7 @@ jobs: - name: No TS/dist changes — required check trivially satisfied if: steps.changed.outputs.ts != 'true' - run: echo "This PR touches no package.json, package-lock.json, ts/** or ts-ci.yml files; build-and-verify-dist has nothing to verify." + run: echo "This PR touches no package.json, package-lock.json, ts/**, ontology/**, scripts/** or ts-ci.yml files; build-and-verify-dist has nothing to verify." # ── Vendored-ontology provenance (W12) ───────────────────────────────────────────── # ontology/kko/PROVENANCE.md recorded a sha256 that NOTHING verified. Consumers across the diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 92e64c8..066632d 100644 Binary files a/ts/dist/index.d.mts and b/ts/dist/index.d.mts differ diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index 92e64c8..066632d 100644 Binary files a/ts/dist/index.d.ts and b/ts/dist/index.d.ts differ diff --git a/ts/dist/index.js b/ts/dist/index.js index 604c6f3..9c827de 100644 Binary files a/ts/dist/index.js and b/ts/dist/index.js differ diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index 135f864..cc867d9 100644 Binary files a/ts/dist/index.mjs and b/ts/dist/index.mjs differ diff --git a/ts/src/vendor-graph.test.ts b/ts/src/vendor-graph.test.ts index 513d07f..158f743 100644 --- a/ts/src/vendor-graph.test.ts +++ b/ts/src/vendor-graph.test.ts @@ -544,6 +544,74 @@ test('the vendored-contract validator has TEETH — a malformed EffectRequest is }).length > 0, 'nested object properties') }) +/** + * …and the verdict that validator produces must be READ. + * + * `RevendorProposal.contractViolations` has always documented "non-empty means the proposal is NOT + * emitted", and nothing implemented it: `analyzeVendorFreshness` sealed every candidate regardless, + * and across the whole repo the field had no production reader — no branch, no `.length`, no throw, + * only its own definition and these tests. A violation computed, sealed, and never read is worse + * than no check at all, because the receipt then carries evidence that the system looked. + * + * The violation here is driven from the GRAPH, not by mutating a finished request: `policyLabels` + * on the pin is declared `['ops','ops']`, `proposeRevendor` joins and re-splits it, and the + * vendored contract's `uniqueItems` rejects it. Exactly the shape a bad register entry would take. + */ +test('a contract-violating proposal is WITHHELD from the seal and NAMED in it', () => { + const store = new HellGraphStore(new AtomSpace('vendor-graph-violation', false)) + ingestVendorFreshness(store, { + ...REGISTER, + artifacts: [ + REGISTER.artifacts[0], + { ...REGISTER.artifacts[1], policy_labels: ['ops', 'ops'] }, + ], + }) + + // The input really does violate the contract — otherwise the rest of this test proves nothing. + const direct = proposeRevendor(store, WARDEN_PIN, { requestedAt: '2026-07-29T00:00:00Z' }) + assert.ok(direct.contractViolations.length > 0, 'the duplicate policy label is a real violation') + + const a = analyzeVendorFreshness(store, { requestedAt: '2026-07-29T00:00:00Z' }) + + // WITHHELD: both pins are stale, so two candidates were built, but only the conforming one is + // emitted. Sealing the bad one alongside the good one is the defect. + assert.equal(a.pins.filter((p) => p.freshnessState === 'stale').length, 2, 'two stale pins') + assert.equal(a.proposals.length, 1, 'only the conforming proposal is emitted') + assert.equal(a.proposals[0]!.effectRequest.target.identifier, 'hellgraph-engine@hellgraph-service') + assert.ok(a.proposals.every((p) => p.contractViolations.length === 0), + 'nothing carrying a violation rides inside the seal') + + // NAMED: withholding on its own would be a silent drop. + assert.ok(a.contractViolations.length > 0, 'the withheld proposal is reported, not dropped') + assert.ok(a.contractViolations.every((v) => v.startsWith('hellgraph-engine@lifecycle-warden: ')), + 'each violation names the pin it came from, like dispositionViolations does') + assert.ok(a.contractViolations.some((v) => /policyLabels/.test(v)), 'and says what was wrong') + + // Sealed, not merely returned: the field is inside the hashed record. + const clean = analyzeVendorFreshness(fixture(), { requestedAt: '2026-07-29T00:00:00Z' }) + assert.deepEqual(clean.contractViolations, [], 'a conforming graph reports none') + assert.equal(clean.proposals.length, 2, 'and withholds nothing') + assert.notEqual(a.hash, clean.hash, 'the withheld violation changes the seal') +}) + +test('contractViolations participates in the seal rather than sitting beside it', () => { + // Two analyses whose ONLY difference is the violation must not seal identically. If the field + // were dropped from the sealed record, these hashes would collide. + const violating = () => { + const s = new HellGraphStore(new AtomSpace('vendor-graph-seal', false)) + ingestVendorFreshness(s, { + ...REGISTER, + artifacts: [REGISTER.artifacts[0], { ...REGISTER.artifacts[1], policy_labels: ['ops', 'ops'] }], + }) + return analyzeVendorFreshness(s, { requestedAt: '2026-07-29T00:00:00Z' }) + } + const x = violating() + const y = violating() + assert.equal(x.hash, y.hash, 'still deterministic') + assert.ok(x.contractViolations.length > 0) + assert.ok(JSON.stringify(x).includes(x.contractViolations[0]!), 'the violation text is in the record') +}) + /** * CodeQL `js/polynomial-redos` on the pre-fix `slug()` trim (`/^-+|-+$/`). `artifact_id` becomes * `pinKey`, which `slug()` puts into the EffectRequest `id` — so a register value walks straight diff --git a/ts/src/vendor-graph.ts b/ts/src/vendor-graph.ts index a11622a..adc3695 100644 --- a/ts/src/vendor-graph.ts +++ b/ts/src/vendor-graph.ts @@ -1228,9 +1228,19 @@ export interface VendorFreshnessAnalysis { risks: ContractCrossingRisk[] /** Blast radius per producing repository, sorted by repo name. */ blastRadius: BlastRadiusReport[] - /** Pins whose DECLARED disposition contradicts the DERIVED state — the only hard violation. */ + /** Pins whose DECLARED disposition contradicts the DERIVED state. */ dispositionViolations: string[] - /** Re-vendor proposals. Emitted, never executed; empty unless `requestedAt` is supplied. */ + /** + * Proposals WITHHELD because they did not validate against the vendored contract, as + * `${pinKey}: ${violation}`. `RevendorProposal.contractViolations` says a violating proposal is + * not emitted; this is where the ones that were not emitted are named, so the withholding is + * visible in the seal instead of being a silent omission. + */ + contractViolations: string[] + /** + * Re-vendor proposals. Emitted, never executed; empty unless `requestedAt` is supplied. + * Contains only proposals that validated — see `contractViolations` for the rest. + */ proposals: RevendorProposal[] /** sha256 over everything above (proof-carrying). */ hash: string @@ -1286,7 +1296,7 @@ export function analyzeVendorFreshness(store: HellGraphStore, opts: AnalyzeOptio const dispositionViolations = pins.filter((p) => !p.dispositionAgrees) .map((p) => `${p.pinKey}: declared '${p.disposition}' but derived '${p.freshnessState}' (${p.rationale})`) - const proposals = opts.requestedAt === undefined ? [] : pins + const candidates = opts.requestedAt === undefined ? [] : pins .filter((p) => p.freshnessState === 'stale') .map((p) => { const repo = store.out(store.out(p.pinId, VFP_EDGE.pinnedAt)[0]?.id ?? '', VFP_EDGE.producedBy)[0] @@ -1298,6 +1308,26 @@ export function analyzeVendorFreshness(store: HellGraphStore, opts: AnalyzeOptio }) }) + // `RevendorProposal.contractViolations` has always said "non-empty means the proposal is NOT + // emitted". Nothing implemented that sentence: every candidate was sealed regardless, and the + // array had no reader anywhere in the repo — no branch, no `.length`, no throw. A violation + // sealed into a receipt nobody reads is worse than no check, because the receipt then carries + // evidence that the system looked. + // + // So the declared rule is now the actual rule: a violating proposal is WITHHELD, and its + // violations are named at the top level the way `dispositionViolations` already is. Withholding + // alone would be a silent drop, and naming alone would leave a consumer free to act on a + // proposal the contract says was never emitted; it takes both. + // + // Reported, not thrown. `analyzeVendorFreshness` is documented PURE and deterministic, and one + // malformed pin must not destroy the verdicts for every other pin in the run — the same reason + // `dispositionViolations` is a list and not an exception. The engine proposes; a membrane gate + // outside it decides. + const proposals = candidates.filter((p) => p.contractViolations.length === 0) + const contractViolations = candidates + .filter((p) => p.contractViolations.length > 0) + .flatMap((p) => p.contractViolations.map((v) => `${p.effectRequest.target.identifier}: ${v}`)) + return sealed({ method: METHOD, contract: { ...EFFECT_REQUEST_CONTRACT }, @@ -1306,6 +1336,7 @@ export function analyzeVendorFreshness(store: HellGraphStore, opts: AnalyzeOptio risks, blastRadius, dispositionViolations, + contractViolations, proposals, }) }