diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 579b35f..585f0e8 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -16,9 +16,18 @@ on: paths: - "package.json" - "ts/**" + - "ontology/**" + - "scripts/**" - ".github/workflows/ts-ci.yml" pull_request: {} +# Both jobs only read the checkout and install from the public registry. Declared at workflow +# level so a job added later inherits least-privilege instead of silently getting the default +# write-scoped token (CodeQL actions/missing-workflow-permissions — which was already open +# against this file on `main` for build-and-verify-dist, not just the new job below). +permissions: + contents: read + jobs: build-and-verify-dist: runs-on: ubuntu-latest @@ -73,3 +82,20 @@ jobs: - name: No TS/dist changes — required check trivially satisfied if: steps.changed.outputs.ts != 'true' run: echo "This PR touches no package.json or ts/** 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 + # estate (prophet-platform apps/owl-reasoner) pin the same digest and assert it at import, so + # the engine — the artifact's source of truth — was the one copy where drift went unnoticed. + # Runs on every PR (cheap, no path filter) so a re-vendor can never land unverified. + kko-provenance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install + run: npm install --no-audit --no-fund + - name: Verify vendored KKO provenance + generated kko-data.ts + run: npm run check:kko diff --git a/ontology/kko/PROVENANCE.md b/ontology/kko/PROVENANCE.md index 38d85ae..9279147 100644 --- a/ontology/kko/PROVENANCE.md +++ b/ontology/kko/PROVENANCE.md @@ -5,14 +5,21 @@ **Bytes:** 327,797 **KKO ontology version:** v2.00 (`owl:versionIRI `) **Namespace:** `http://kbpedia.org/ontologies/kko#` -**Retrieved:** 2026-07-19 +**Retrieved:** 2026-07-19 · **Provenance re-verified against upstream:** 2026-07-29 -## Source (sovereign) +## Source (sovereign, pinned) Vendored from the estate's sovereign fork, not upstream directly: -- **`SocioProphet/kbpedia`** @ `master`, path `versions/2.10/kko-demo.n3` - (raw: `https://raw.githubusercontent.com/SocioProphet/kbpedia/master/versions/2.10/kko-demo.n3`) +- **`SocioProphet/kbpedia`** @ commit `3f888b397255b69d1439fd95823e97011ed9440b` (branch `master`), + path `versions/2.10/kko-demo.n3` + (raw: `https://raw.githubusercontent.com/SocioProphet/kbpedia/3f888b397255b69d1439fd95823e97011ed9440b/versions/2.10/kko-demo.n3`) - Upstream: **`KBpedia/kbpedia`** (the org formerly known as Cognonto). +This record previously cited `@ master`. A branch name is a MOVING reference, not a pin: the same +retrieval run later can return different bytes while still claiming this provenance. `master` has +not moved since 2019-04-09, so the two resolve identically today — which is exactly why the +difference stays invisible until it isn't. `scripts/check-kko-provenance.mjs` now refuses a record +that pins no commit. + ## What it is The **KKO upper ontology** — the Peircean-grounded typology structure that types the KBpedia Knowledge Graph. This file carries the KKO TBox: **203 `owl:Class` declarations, 167 @@ -26,7 +33,32 @@ KBpedia and the KKO are released under **CC-BY-4.0**. Attribution required; see and . This vendored copy preserves that attribution and does not modify the ontology content. +## Where this record is ENFORCED +Until 2026-07-29 everything above was verified by **nothing** — the sha256 was a correct line in a +Markdown file, connected to no check. `scripts/check-kko-provenance.mjs` (npm run `check:kko`, run +in `ts-ci`) now asserts, on every PR: + +1. `kko-2.10.n3` still hashes to the **SHA-256** declared above, and is still the declared byte + length. The record is the source of the expectation, so record and artifact cannot silently + disagree. +2. This file pins a **commit**, not a branch. +3. `ts/src/kko-data.ts` is exactly what `scripts/gen-kko.mjs` reproduces from this `.n3` — so the + ontology the engine actually SHIPS derives from the vendored file whose provenance is published + here. (Same doctrine as the repo's `ts/dist` staleness gate: a generated artifact must be + reproducible from its source, or the source is not the source.) + +**This digest is load-bearing beyond this repo.** prophet-platform vendors byte-identical copies of +this TBox (`apps/owl-reasoner/src/owl_reasoner/data/kko-2.10.n3`) and pins the SAME constant, +asserted at import. Moving this digest without moving the consumers' is the drift these gates +exist to catch — change them in one PR each, never independently. + ## Regeneration The compact hierarchy consumed by the engine (`ts/src/kko-data.ts`) is generated from this file by `scripts/gen-kko.mjs` (parses with the engine's own `parseTurtle`). Re-run after re-vendoring a newer KKO version and commit both the `.n3` and the regenerated `kko-data.ts`. + +Re-vendoring checklist: +1. Copy the new `.n3` from the pinned `SocioProphet/kbpedia` path; update the commit pin above. +2. `shasum -a 256 ontology/kko/kko-2.10.n3` → update **SHA-256** and **Bytes** above. +3. `node --import tsx scripts/gen-kko.mjs` → commit the regenerated `ts/src/kko-data.ts`. +4. `npm run check:kko` must pass, then update the consumer pins listed above. diff --git a/package.json b/package.json index e50bcc5..de9183e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "prepare": "tsup", "typecheck": "tsc -p ts/tsconfig.json --noEmit", "test": "node --import tsx --test ts/src/*.test.ts", - "check:dist": "tsup && git diff --quiet -- ts/dist || (echo 'ERROR: ts/dist is stale vs ts/src — run: npm run build, then commit ts/dist' && exit 1)" + "check:dist": "tsup && git diff --quiet -- ts/dist || (echo 'ERROR: ts/dist is stale vs ts/src \u2014 run: npm run build, then commit ts/dist' && exit 1)", + "check:kko": "node --import tsx scripts/check-kko-provenance.mjs" }, "optionalDependencies": { "autobase": "7.28.1", diff --git a/scripts/check-kko-provenance.mjs b/scripts/check-kko-provenance.mjs new file mode 100644 index 0000000..a5098cd --- /dev/null +++ b/scripts/check-kko-provenance.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * KKO vendored-ontology provenance guard. + * + * ── Why this exists (W12 inventory hygiene, 2026-07) ───────────────────────────────── + * `ontology/kko/PROVENANCE.md` is the best provenance record in the estate — source, licence, + * version IRI, byte count, sha256. It is also, until this script, verified by NOTHING. The + * digest was a line in a Markdown file: correct, and connected to no check. Two consumers + * (prophet-platform's owl-reasoner and nugget-extractor's KKO type refs) pin the SAME digest and + * now assert it at import, so the engine — the artifact's source of truth — was the one copy + * where drift would have gone unnoticed. + * + * ── What it enforces ───────────────────────────────────────────────────────────────── + * 1. `ontology/kko/kko-2.10.n3` still hashes to the sha256 recorded in PROVENANCE.md, and is + * still the recorded byte length. The doc is the source of the expectation, so the doc and + * the artifact can never silently disagree. + * 2. PROVENANCE.md pins a COMMIT, not just a branch. `@ master` is a moving reference: re-run + * the retrieval later and you may get different bytes while claiming the same provenance. + * 3. The generated `ts/src/kko-data.ts` is exactly what `scripts/gen-kko.mjs` produces from + * that .n3 — i.e. the ontology the engine actually SHIPS derives from the vendored source. + * A hand-edit to kko-data.ts, or a re-vendored .n3 without a regeneration, otherwise leaves + * the runtime ontology silently diverged from the file whose provenance we publish. + * (Same doctrine as this repo's existing ts/dist staleness gate — a generated artifact must + * be reproducible from its source, or the source is not the source.) + * + * Read-only: it regenerates into memory and compares. It never writes ts/src. + * + * Run: node --import tsx scripts/check-kko-provenance.mjs + */ +import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const die = (m) => { console.error(`✗ ${m}`); process.exit(1) } +const url = (p) => fileURLToPath(new URL(p, import.meta.url)) + +const n3Path = url('../ontology/kko/kko-2.10.n3') +const mdPath = url('../ontology/kko/PROVENANCE.md') +const dataPath = url('../ts/src/kko-data.ts') + +// ── 1) the artifact matches the digest ITS OWN provenance record declares ── +const doc = readFileSync(mdPath, 'utf8') +const declared = (doc.match(/\*\*SHA-256:\*\*\s*`([0-9a-f]{64})`/) || [])[1] +if (!declared) die('ontology/kko/PROVENANCE.md does not declare a **SHA-256:** `<64hex>` — the record must state what the artifact should be') + +const bytes = readFileSync(n3Path) +const actual = createHash('sha256').update(bytes).digest('hex') +if (actual !== declared) + die(`vendored KKO TBox DRIFTED: sha256 ${actual} != ${declared} declared in ontology/kko/PROVENANCE.md.\n` + + ` This digest is pinned by consumers too (prophet-platform apps/owl-reasoner asserts it at import).\n` + + ` Re-vendor, then update PROVENANCE.md AND every consumer pin in the same change.`) + +const declaredBytes = Number(((doc.match(/\*\*Bytes:\*\*\s*([\d,]+)/) || [])[1] || '0').replace(/,/g, '')) +if (declaredBytes && bytes.length !== declaredBytes) + die(`vendored KKO TBox is ${bytes.length} bytes, PROVENANCE.md declares ${declaredBytes}`) + +// ── 2) the source must be PINNED, not a moving branch ref ── +// NB: search for the commit pin only AFTER removing the sha256 from the text. A naive +// /[0-9a-f]{40}/ over the whole document matches a 40-char window INSIDE the 64-char sha256 — +// the check would have been satisfied by the very digest it is supposed to be independent of. +// (A checker that validates itself validates nothing; this repo's estate has been bitten by +// exactly that shape before.) +const withoutDigest = doc.split(declared).join('') +const commitPin = (withoutDigest.match(/\b([0-9a-f]{40})\b/) || [])[1] +if (!commitPin) + die('ontology/kko/PROVENANCE.md pins no commit sha. "@ master" is a MOVING reference — the same ' + + 'retrieval later can return different bytes and still claim this provenance. Pin the commit.') +for (const needle of ['SocioProphet/kbpedia', 'CC-BY-4.0']) + if (!doc.includes(needle)) die(`ontology/kko/PROVENANCE.md does not record ${needle}`) + +// ── 3) the SHIPPED ontology is reproducible from the vendored source ── +// Imported lazily and by URL so this file stays plain .mjs; needs `node --import tsx`. +const { parseKko } = await import('../ts/src/kko.ts') +const onto = parseKko(bytes.toString('utf8')) +onto.classes.sort((a, b) => a.iri.localeCompare(b.iri)) // same deterministic order gen-kko.mjs uses + +const rows = onto.classes + .map((c) => { + const label = c.label !== undefined ? `, label: ${JSON.stringify(c.label)}` : '' + return ` { iri: ${JSON.stringify(c.iri)}${label}, subClassOf: ${JSON.stringify(c.subClassOf)} },` + }) + .join('\n') + +const expected = `// GENERATED by scripts/gen-kko.mjs from ontology/kko/kko-2.10.n3 — do not edit by hand. +// KKO ${onto.version} · ${onto.classes.length} classes. Regenerate after re-vendoring KKO. +import type { KkoClass } from './kko' + +export const KKO_VERSION = ${JSON.stringify(onto.version)} +export const KKO_CLASSES: KkoClass[] = [ +${rows} +] +` + +if (readFileSync(dataPath, 'utf8') !== expected) + die('ts/src/kko-data.ts is NOT what scripts/gen-kko.mjs produces from ontology/kko/kko-2.10.n3.\n' + + ' The ontology the engine ships has diverged from the vendored file whose provenance we publish.\n' + + " Run: node --import tsx scripts/gen-kko.mjs then commit ts/src/kko-data.ts") + +console.log(`✓ KKO provenance verified — ${actual.slice(0, 16)}… (${bytes.length.toLocaleString()} bytes), ` + + `${onto.classes.length} classes ${onto.version}; ts/src/kko-data.ts reproduces from source`)