Skip to content

DO NOT MERGE — retrospective Copilot review of merged PR #35 (058f87e, KKO provenance gate) - #39

Closed
mdheller wants to merge 1 commit into
review/pr33-retrospectivefrom
review/pr35-retrospective
Closed

DO NOT MERGE — retrospective Copilot review of merged PR #35 (058f87e, KKO provenance gate)#39
mdheller wants to merge 1 commit into
review/pr33-retrospectivefrom
review/pr35-retrospective

Conversation

@mdheller

Copy link
Copy Markdown
Member

DO NOT MERGE — retrospective review only

This PR exists only to obtain a Copilot review of content that is already on main.

PR #35 ("W12: make ontology/kko/PROVENANCE.md a gate instead of a record") merged as
058f87e73255cac57f26ede50fc1f8501aab2897 on 2026-07-30 without a Copilot review. This repo does
not auto-request Copilot, and Copilot cannot review a closed PR.

The diff below is byte-for-byte PR #35: base is e4ae226 (main immediately before the merge), head
is 058f87e (the merge commit itself). Nothing here is new work.

  • Base branch: review/pr33-retrospectivee4ae226
  • Head branch: review/pr35-retrospective058f87e
  • Both branches are throwaway. Neither targets main. Delete both once the review is captured.

What already happened on #35

  • Human adversarial review
  • CodeQL pass, zero open alerts on the merge ref
  • Full local test run

This is a missing second opinion, not a known defect.

Where a reviewer's attention is most useful

scripts/check-kko-provenance.mjs is a gate, so its failure modes matter more than its size:

  1. The commit-pin extraction strips the one declared sha256 before scanning for a 40-hex commit. Is
    that sufficient if PROVENANCE.md ever carries a second 64-hex digest?
  2. The byte-length check is if (declaredBytes && ...) — omitting **Bytes:** from the record
    silently skips the check rather than failing.
  3. The regeneration comparison rebuilds the expected kko-data.ts as a template string; does it
    stay faithful to scripts/gen-kko.mjs if that generator changes?

* W12: make ontology/kko/PROVENANCE.md a gate instead of a record

The engine's KKO provenance is the best in the estate — source, licence, version IRI,
byte count, sha256 — and until now it was verified by NOTHING. The digest was a correct
line in a Markdown file, connected to no check.

That matters beyond this repo: prophet-platform vendors byte-identical copies of this
TBox and now asserts the SAME constant at import. The engine, the artifact's source of
truth, was the one copy where drift would have gone unnoticed.

scripts/check-kko-provenance.mjs (npm run check:kko, wired into ts-ci on every PR)
asserts three things:

  1. kko-2.10.n3 still hashes to the SHA-256 the record declares, and is still the
     declared byte length. The record is the source of the expectation, so record and
     artifact cannot silently disagree.
  2. The record pins a COMMIT. It previously cited "@ master" — a moving reference: the
     same retrieval later can return different bytes while claiming this provenance.
     Now pinned to 3f888b397255b69d1439fd95823e97011ed9440b (verified against upstream
     2026-07-29; master has not moved since 2019-04-09, which is exactly why the
     difference stays invisible until it isn't).
  3. ts/src/kko-data.ts is exactly what scripts/gen-kko.mjs reproduces from that .n3 —
     so the ontology the engine SHIPS derives from the file whose provenance we publish.
     A hand-edit, or a re-vendor without regeneration, otherwise leaves the runtime
     ontology diverged from its stated source. Same doctrine as the existing ts/dist
     staleness gate.

Read-only with respect to ts/src: it regenerates into memory and compares, never writes.

Teeth verified — each check fails on a real tamper:
  1. length-preserving :Monads -> :Places re-parent (valid Turtle, still 327,797 bytes) -> refused
  2. commit pin reverted to "master" -> refused
  3. hand-edited label in the generated kko-data.ts -> refused
  clean tree -> exit 0

Note: the commit-pin check deliberately strips the sha256 from the document before
searching for a 40-hex commit — a naive scan matches a 40-char window INSIDE the 64-char
digest and would have been satisfied by the very value it is meant to be independent of.

* review: least-privilege token for ts-ci, and gate the paths the gate guards

Two findings from review of #35.

1. CodeQL actions/missing-workflow-permissions fired on the new kko-provenance
   job (alert 36, ts-ci.yml:92). Accepted. Fixed at WORKFLOW level rather than
   on the new job, because the same rule was already open against this file on
   `main` for build-and-verify-dist (alert 33, ts-ci.yml:24) — a job-level
   block would have closed the alert this PR introduced and left the one it
   inherited. Declaring it once also means a job added later inherits
   least-privilege instead of silently getting the default write-scoped token.
   Both jobs only read the checkout and install from the public registry, so
   `contents: read` is sufficient; verified both still pass locally.

2. `on: push` was path-filtered to package.json / ts/** / the workflow itself.
   The kko-provenance job guards ontology/kko/** and scripts/**, so on a push
   that touched ONLY those paths the gate did not run at all. It always runs on
   pull_request (no path filter there), which is the merge path, so this was
   not a hole in practice — but the trigger should cover what the job guards.
   Added ontology/** and scripts/**.

Gate-fires proof (all six failure branches, each restored afterwards):
  artifact digest drift          -> rc=1
  commit pin replaced by @master -> rc=1
  kko-data.ts hand-edited        -> rc=1
  declared byte count wrong      -> rc=1
  declared sha256 removed        -> rc=1
  source repo no longer recorded -> rc=1
  untouched tree                 -> rc=0
@mdheller
mdheller requested a review from Copilot July 30, 2026 02:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds an enforced provenance “gate” for the vendored KKO ontology to prevent silent drift between the recorded provenance, the vendored .n3, and the generated kko-data.ts.

Changes:

  • Introduces scripts/check-kko-provenance.mjs to validate SHA-256/bytes, ensure provenance is commit-pinned, and ensure ts/src/kko-data.ts is reproducible from the .n3.
  • Wires the check into npm scripts (check:kko) and GitHub Actions (kko-provenance job).
  • Updates ontology/kko/PROVENANCE.md to pin an upstream commit and document the enforced checks.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
scripts/check-kko-provenance.mjs New guard script enforcing provenance + reproducibility invariants.
package.json Adds check:kko npm script to run the guard.
ontology/kko/PROVENANCE.md Pins upstream commit and documents enforcement/regeneration checklist.
.github/workflows/ts-ci.yml Runs the provenance guard in CI and expands push path filters.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +63 to +64
const withoutDigest = doc.split(declared).join('')
const commitPin = (withoutDigest.match(/\b([0-9a-f]{40})\b/) || [])[1]
Comment on lines +53 to +55
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}`)
Comment on lines +77 to +92
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}
]
`
Comment on lines +94 to +97
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")

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (3)

scripts/check-kko-provenance.mjs:55

  • This currently treats a missing **Bytes:** field as 0 and silently skips the byte-length verification (if (declaredBytes && ...)). Given this script is intended as a provenance gate, it should fail if **Bytes:** is absent/unparseable, not downgrade the check. Consider explicitly detecting a missing match and calling die(...), then comparing unconditionally once parsed.
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}`)

scripts/check-kko-provenance.mjs:92

  • This duplicates scripts/gen-kko.mjs’s output formatting inline. That means any future formatting/header change in the generator will require careful synchronized updates here (or the gate risks blocking legitimate generator changes). To reduce drift, consider factoring the generator’s render logic into a shared module (e.g., export a renderKkoDataTs(onto) function) used by both gen-kko.mjs and this check, so the gate validates exactly what the generator produces without duplicating templates.
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}
]
`

scripts/check-kko-provenance.mjs:97

  • A raw string equality check is sensitive to line endings (CRLF vs LF) and potentially a final trailing newline. This can cause local failures on Windows checkouts (e.g., with core.autocrlf) even when the semantic content matches what the generator would write. If you want the gate to be platform-robust while still strict, normalize both strings (e.g., convert \\r\\n\\n, and optionally enforce/normalize a single trailing newline) before comparison.
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")

Comment on lines +63 to +67
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.')
@mdheller

Copy link
Copy Markdown
Member Author

Evaluation — and this PR should be CLOSED, not merged

This is a retrospective review container for already-merged #35 (058f87e). Its purpose was to get Copilot's eyes on the KKO provenance gate after the fact; that has now happened twice. It must not be merged — merging it would replay merged history against review/pr33-retrospective. Recommend closing once the findings below are carried into a real fix PR against main.

Copilot coverage: 4 of 4 files — the only one of tonight's four PRs with complete coverage — though the review still carries the degraded-mode banner. Two rounds landed: 5 visible comments and 3 in the <details>Comments suppressed due to low confidence</details> block.

Real — and the sharpest of the night

scripts/check-kko-provenance.mjs:53-55 — the byte-length check cannot fail. Raised in both the visible and suppressed channels.

const declaredBytes = Number(((doc.match(/\*\*Bytes:\*\*\s*([\d,]+)/) || [])[1] || '0').replace(/,/g, ''))
if (declaredBytes && bytes.length !== declaredBytes)

A missing or unparseable **Bytes:** becomes 0, and if (0 && …) never fires. Verified by execution against main: deleting the **Bytes:** line from PROVENANCE.md leaves the gate green, and it still prints

✓ KKO provenance verified — d907919fb40f20ed… (327,797 bytes), 168 classes …

— the byte count in that line is the actual file length, not the declared one, so the output reads as though verification happened when the record no longer declares anything to verify. For a gate whose stated job is that "the record and the artifact cannot silently disagree", a record that omits the field is the one case it must reject, and it is the one case it accepts.

The irony is worth recording: eight lines below sits the comment "A checker that validates itself validates nothing; this repo's estate has been bitten by exactly that shape before." The author was alert to the defect in one place and blind to it in the next.

Fix: die() when the field is absent or unparseable, then compare unconditionally.

Real, lower severity

  • :64 — commit-pin detection can false-pass (raised twice). Already partly defended: withoutDigest strips the declared sha256 before scanning for /\b[0-9a-f]{40}\b/, which closes the obvious self-match. The residual is real though — any other 64-hex digest in the document still contains 40-hex windows. Anchor on the commit marker rather than scanning for a bare 40-hex run.
  • :92 — the checker re-renders kko-data.ts from an embedded template duplicating gen-kko.mjs. Genuine drift hazard: a formatting change in the generator either breaks the gate or requires lockstep edits. Factoring a shared renderKkoDataTs(onto) used by both is the right shape.

Rejected

  • :97 — CRLF normalization. Speculative here. The comparison is between a freshly rendered in-memory string and a file the same repo generates and commits; the gate runs on ubuntu-latest. Normalizing would also weaken a gate that is deliberately byte-exact. Worth revisiting only if a Windows contributor actually hits it.

Disposition

None of this is fixed in this PR, because this PR is not a place to fix things. Carrying the byte-check fix and the commit-pin anchor into a PR against main is the follow-up.

@mdheller

Copy link
Copy Markdown
Member Author

Review complete — closing, not merging

Same terminal state as #38, and for the same reason: this is byte-for-byte the already-merged content of #35 (058f87e), staged against a throwaway base (review/pr33-retrospective, i.e. #38's head) purely to obtain the Copilot review that #35 merged without. It was never merge-eligible. Closing is correct.

Copilot left 5 inline comments and 3 more inside the <details>Comments suppressed due to low confidence</details> block on the review body — a channel returned by neither issues/39/comments nor pulls/39/comments. Recording all of them here so closing does not drop them.

Findings the review produced, and where they went

# Finding Disposition
1 Byte-length check is if (declaredBytes && …) with a || '0' fallback — a missing or unparseable **Bytes:** silently skips verification and still prints a success line naming a byte count Fixed — #41. This was the gate's worst failure mode: it manufactured the evidence that it ran. **Bytes:** is now required and the comparison unconditional
2 Commit-pin detection could false-pass on any other 40-hex run in the document Fixed — #41, with a correction worth keeping: the specific mechanism reported (a 40-hex window inside a 64-hex digest) does not reproduce — the \b anchors on both ends already block every contiguous framing of a 64-hex run, and the old code failed correctly on that case when constructed. The broader weakness — any unrelated 40-hex token anywhere in the file satisfying the pin check — is real and is what #41 closes, by anchoring on the commit `<40-hex>` markup
3 The regeneration comparison rebuilds ts/src/kko-data.ts from a template string that duplicates scripts/gen-kko.mjs's output formatting — any generator formatting change requires a synchronised edit here, or the gate blocks legitimate generator changes NOT COVERED — no PR, no task. Maintainability/drift hazard on a required-path gate. The fix is to factor the generator's render logic into a shared module both call
4 The regeneration comparison is a raw string equality check, so it is sensitive to CRLF vs LF and to a trailing newline — a Windows checkout with core.autocrlf fails the gate even when content matches semantically NOT COVERED — no PR, no task. Currently latent because CI is Linux-only; it becomes real the first time this is run on a Windows checkout

Items 3 and 4 are carried forward to the estate backlog by this comment. Neither is a correctness hole in the gate today; both are ways the gate produces a wrong answer for the wrong reason later — 3 by blocking good changes, 4 by failing on a platform difference.

Branches

review/pr35-retrospective and review/pr33-retrospective are throwaway and can be deleted, along with review-base/pr33, now that both retrospective PRs are closed. Not deleting them here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants