Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions .github/workflows/ts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Comment on lines +61 to +76

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the guard — both concerns resolve to a false RED under reformatting, never a false pass, so it fails safe. (1) If on.push.paths were rewritten as unquoted scalars, declared would be empty and the very next line if not declared: sys.exit('::error::...has gone blind') fails the step loudly — it does not silently pass. (2) end = next(...) raising StopIteration if push: were the last on: key is likewise an uncaught error → red job, not a skipped build. The guard's job is to catch drift between on.push.paths and the PR change-detector, and it has teeth: verified that adding a docs/** push entry the TS_PATHS regex doesn't cover makes it exit 1 with "would report build-and-verify-dist green without building anything." Falling back to len(wf) and accepting unquoted items is reasonable hardening, but there is no false-green to close here.

PY
if [ "${{ github.event_name }}" != "pull_request" ]; then
# push is already path-filtered to TS-relevant paths — always build.
echo "ts=true" >> "$GITHUB_OUTPUT"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Binary file modified ts/dist/index.d.mts
Binary file not shown.
Binary file modified ts/dist/index.d.ts
Binary file not shown.
Binary file modified ts/dist/index.js
Binary file not shown.
Binary file modified ts/dist/index.mjs
Binary file not shown.
68 changes: 68 additions & 0 deletions ts/src/vendor-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,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
Expand Down
37 changes: 34 additions & 3 deletions ts/src/vendor-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,9 +968,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.
*/
Comment on lines +973 to +983

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — the doc comment should say so. contractViolations is derived from the same candidates list as proposals (both are produced by the revendor pass), so it is likewise empty unless requestedAt is supplied. The behavior is already that; only the doc omits the constraint that proposals states explicitly. Worth a one-line doc tweak to name it; not a behavior change.

proposals: RevendorProposal[]
/** sha256 over everything above (proof-carrying). */
hash: string
Expand Down Expand Up @@ -1026,7 +1036,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]
Expand All @@ -1038,6 +1048,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 },
Expand All @@ -1046,6 +1076,7 @@ export function analyzeVendorFreshness(store: HellGraphStore, opts: AnalyzeOptio
risks,
blastRadius,
dispositionViolations,
contractViolations,
proposals,
})
}
Expand Down
Loading