From 738c038bcb80e12beba46d762709880deaf16e39 Mon Sep 17 00:00:00 2001 From: "Shaun \"Oden\" Marshall" <40664141+Odenknight@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:16:32 -0400 Subject: [PATCH 01/10] Clarify observation failure and full-history verification requirements --- README.md | 6 ++ docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md | 68 +++++++++++++++++++ ...un-retrieval-observation-qualification.mjs | 1 + 3 files changed, 75 insertions(+) create mode 100644 docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md diff --git a/README.md b/README.md index 464f49a..0eeda4e 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,12 @@ authority. ## Build and verification +Use a full Git clone for verification: historical fixture tests read exact +commits and cannot reliably run from a shallow clone or a source ZIP. Check +with `git rev-parse --is-shallow-repository`; if it prints `true`, run +`git fetch --unshallow origin` before testing. In GitHub Actions, use +`actions/checkout` with `fetch-depth: 0`. + Run build and packaging steps separately from running tests: npm preparation can rebuild `dist/` and invalidate a concurrent test run. diff --git a/docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md b/docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md new file mode 100644 index 0000000..e3d97d5 --- /dev/null +++ b/docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md @@ -0,0 +1,68 @@ +# Usefulness audit follow-up — September 8, 2026 + +Baseline: Engine `650eab4a6752227cae336d7556a57826c22a0d5a`. + +## Verified defect and bounded repair + +Scheduled observation failed September 7 and 8. September 8 run: +https://github.com/Odenknight/GKOS-Engine/actions/runs/34206749198 +Artifact 10048073691 reports `OBS_INDEX_FAILED`. Build, package and frozen +immutability gates passed. This is not sufficient evidence of a performance +regression. + +Local reproduction on Linux / Node 24.19.0 exposed +`GKX_EVAL_OBSERVATION_INDEX_RECEIPT_MISMATCH`. Provider counts match (313 calls, +10,000 items), request-sequence digest matches, and vector stage is active. +The frozen generator binds Engine 2.1.2; the current manifest binds 2.2.0. +Expected initial projection: `retrieval:1f7d014b0dd57096f6437e1c`. +Actual initial projection: `retrieval:6d9db8b375561241723576b4`. + +This change prints the existing allowlisted failure code to stderr so CI is +no longer silent. It does not print caught exceptions, source text, paths, +provider data or query results, and preserves receipt and exit behavior. +README now explains full-history cloning for historical fixture tests. +The observation qualification test file passes all 12 tests locally. +The full observation remains failing; no current performance qualification +is claimed. + +## Coding-agent next step: observation qualification + +Preserve the existing 2.1.2 fixture and its digests. Add a separately versioned +2.2 observation fixture and runner path with independently checked expected +projection identities. Keep a named historical replay against an exact 2.1.2 +source commit. Do not make the frozen test pass by overriding the production +engine version, accepting arbitrary generated digests, removing checks, or +relaxing latency thresholds. Current and historical receipts must name the +actual executed revision and fixture. Update workflow-contract tests for the +new lanes. Require real FTS5 10k indexing, one-item incremental reuse, +clean-rebuild equivalence, query determinism, and the existing p95 budget. +Then run hosted qualification against the exact candidate. + +## Basic remaining instructions + +1. Engine: complete the observation successor above and issue #44 release + qualification (durable no-op receipts, Linux/Windows evidence, performance, + recovery, 24-hour soak and exact-artifact consumer checks). +2. Owner: approve the exact immutable Engine release only after its evidence + passes. `npm view gkos-engine version` returned 2.0.1 today. Publish the + approved artifact through the npm owner account, then install that registry + version in a clean consumer project and smoke-test it. Do not publish + development main merely to close the version gap; tags trigger publication. +3. Kosmos: qualify the exact distributable with its pinned Engine; check the + manifest, release assets and installation in a fresh Obsidian vault. Owner + signs into https://community.obsidian.md, links GitHub, and submits the + repository using https://docs.obsidian.md/plugins/releasing/submit-plugin. + Release assets must include main.js, manifest.json and styles.css if used. + Verify desktop/mobile claims against actual supported environments. +4. Standard: recruit an independent implementer for one narrow, published + profile and share versioned fixtures, positive cases and deliberate failure + cases. Record limitations and independently produced evidence. A second + wrapper around this Engine is not an independent implementation. +5. Lite projects: retain their declared scopes. Complete native qualification + for Engine-Lite; accept maintenance/security fixes for frozen Kosmos-Lite. +6. Adoption: run a small external pilot, record task completion and failures, + and improve onboarding from those results. Stars, forks and release asset + counts do not establish zero users. Distribution improves access but does + not by itself prove utility or ecosystem adoption. + +No package, release or community-directory submission is made by this change. diff --git a/scripts/run-retrieval-observation-qualification.mjs b/scripts/run-retrieval-observation-qualification.mjs index 5001102..5b09f4f 100644 --- a/scripts/run-retrieval-observation-qualification.mjs +++ b/scripts/run-retrieval-observation-qualification.mjs @@ -1617,6 +1617,7 @@ export async function main(argv = process.argv.slice(2)) { ? error.message : "OBS_REPORT_INVALID"; await writeObservationFailureReceipt(artifactRoot, source, code); + process.stderr.write(`phase4 retrieval observation: ${code}; inspect observation-receipt.json\n`); process.exitCode = 1; } } From 23301a735165c5585ef3092a1d87691c002917a5 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 10:03:57 -0400 Subject: [PATCH 02/10] Qualify Engine 2.2 retrieval with separate pinned observation fixture --- .github/workflows/observation-2.2.yml | 46 + .../phase4-retrieval-observation.yml | 8 +- .../v1/change-inventory.json | 66 +- docs/OBSERVATION-2.2-QUALIFICATION.md | 55 + .../2026-09-08-release-220-starting-state.md | 61 + ...rate-retrieval-observation-fixture-2.2.mjs | 37 + scripts/observation-2.2-material.mjs | 56 + ...etrieval-observation-qualification-2.2.mjs | 1685 +++++++++++++++++ test/retrieval-observation-2.2.test.mjs | 75 + ...trieval-observation-qualification.test.mjs | 6 +- 10 files changed, 2086 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/observation-2.2.yml create mode 100644 docs/OBSERVATION-2.2-QUALIFICATION.md create mode 100644 evidence/2026-09-08-release-220-starting-state.md create mode 100644 scripts/generate-retrieval-observation-fixture-2.2.mjs create mode 100644 scripts/observation-2.2-material.mjs create mode 100644 scripts/run-retrieval-observation-qualification-2.2.mjs create mode 100644 test/retrieval-observation-2.2.test.mjs diff --git a/.github/workflows/observation-2.2.yml b/.github/workflows/observation-2.2.yml new file mode 100644 index 0000000..9db3c0f --- /dev/null +++ b/.github/workflows/observation-2.2.yml @@ -0,0 +1,46 @@ +name: Engine 2.2 retrieval observation +on: + push: + branches: [main, 'release/**'] + pull_request: + schedule: + - cron: '37 4 * * *' + workflow_dispatch: +permissions: + contents: read +jobs: + current-2-2: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run typecheck && npm run build && npm run pack:check + - name: Build private runner + run: ./node_modules/.bin/esbuild scripts/run-retrieval-observation-qualification-2.2.mjs --bundle --platform=node --format=esm --target=node24 --outfile="$RUNNER_TEMP/observation-2.2.mjs" + - name: Execute exact current source + run: | + mkdir -m 700 "$RUNNER_TEMP/observation-2.2" + node "$RUNNER_TEMP/observation-2.2.mjs" --mode observation --artifact-root "$RUNNER_TEMP/observation-2.2" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: observation-2.2-${{ github.sha }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/observation-2.2/performance-sample-plan.json + ${{ runner.temp }}/observation-2.2/observation-receipt.json + ${{ runner.temp }}/observation-2.2/observation-report.json + if-no-files-found: error + retention-days: 30 + - run: git diff --exit-code && test -z "$(git status --porcelain)" + if: always() diff --git a/.github/workflows/phase4-retrieval-observation.yml b/.github/workflows/phase4-retrieval-observation.yml index 04ecbd2..dccf770 100644 --- a/.github/workflows/phase4-retrieval-observation.yml +++ b/.github/workflows/phase4-retrieval-observation.yml @@ -1,4 +1,4 @@ -name: Phase 4 Retrieval Observation +name: Phase 4 Retrieval Observation (historical Engine 2.1.2) on: schedule: @@ -16,9 +16,15 @@ jobs: observe: runs-on: ubuntu-latest timeout-minutes: 45 + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: + # Exact last successful historical implementation; never current main. + ref: d81f9d1351f1a9228650a840629191a92f2dfb22 fetch-depth: 0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index e376b9b..7f3960c 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -389,11 +389,17 @@ "after": "81cb20749c449c813efc9c10c1a1315a17385e8149b23303ab347482a70d02f2", "rationale": "Runtime modernization and stability: qualify Node 22/24/26 and execute dedicated watcher observations through one bounded exact-latency retry wrapper." }, + { + "path": ".github/workflows/observation-2.2.yml", + "before": null, + "after": "26e2e20eea2b2eb1b7914a6f98433e83383930ecfe5278df73e394b8833792f7", + "rationale": "Add a distinct fail-closed Linux 2.2 observation workflow with bounded artifact upload." + }, { "path": ".github/workflows/phase4-retrieval-observation.yml", "before": "8335d9b88016414584117202b7d083393d360a1c", - "after": "033df13af0bfb9f02b1c8679b8d04cf5c32b348718022386b1f919e389254662", - "rationale": "Workflow equivalence repair: keep the standalone Phase 4 observation lane byte-equivalent to its CI bridge after the approved exact checkout/setup-node v5 pin upgrade." + "after": "2addd303a6922d80a936f46edd15be19f24429cdf551473bb6525e08696bcda9", + "rationale": "Name historical Engine 2.1.2 lane and execute the exact last successful historical source with full history." }, { "path": ".github/workflows/runtime-qualification.yml", @@ -434,8 +440,8 @@ { "path": "README.md", "before": "d14be2d540c012f0cd8a5d52ad68142b7d35b9d3", - "after": "e13d2715484aa0eff67d04c691de229cfbcf011444f6c3fe5f8a62e1cd0a99c9", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "23fbc3aab2f40022913d5b077585ccf116f89df36b6a0e5edc958193409a4d2b", + "rationale": "Retain current capability documentation and add reviewed full-history verification instructions from PR #47." }, { "path": "ROADMAP.md", @@ -617,12 +623,24 @@ "after": "6514c8e48174017a248960b5a1512739c8416abbb76b5b766429e6dc54913c29", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "docs/OBSERVATION-2.2-QUALIFICATION.md", + "before": null, + "after": "2e8da20ad7853f5be1ea644f2f1f22c9bc6d2bb41ce8dcf1e5d6033a818aecef", + "rationale": "Document distinct historical/current identities, workload, digest derivation and limitations." + }, { "path": "docs/RELEASE-STATUS.md", "before": null, "after": "c4be9892ebed0bb9db87c46f91f37a7bbff1767068af66e4122deafedba3c652", "rationale": "Document current source capabilities, release status and exact merged verification evidence; no runtime or release activation." }, + { + "path": "docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md", + "before": null, + "after": "89935024c69ad6edef09a40b24eeed073d50c2614bf4ad2ac5d1c147e599de6f", + "rationale": "Preserve PR #47 exact bounded failure diagnosis and follow-up record." + }, { "path": "docs/VERSION-PROFILE-COMPATIBILITY.md", "before": "e2bcc225192fee89b0272ea446a1c3467a22fb9b", @@ -695,6 +713,12 @@ "after": "34c42d2bea3da6934c575bd27d29b13d02bc08b81fd60992a8543b46c74389af", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "evidence/2026-09-08-release-220-starting-state.md", + "before": null, + "after": "abebb07f5e89794a9a533e81a6a51498041cd63b153e8963fa916bcc22ee9626", + "rationale": "Record exact reconciled release starting coordinates, open work, environment and unmet gates." + }, { "path": "examples/managed-moc.mjs", "before": null, @@ -737,12 +761,36 @@ "after": "0413a1f61fde21ab050b4277c96c4ba7586784ceced871ee2cabf94119f3fe9d", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "scripts/generate-retrieval-observation-fixture-2.2.mjs", + "before": null, + "after": "83805dd8b3e50accf7eba91ce7bd9b08345c58978a2021c7cb27e45364cbf3aa", + "rationale": "Bind separately versioned 2.2 fixture to reviewed fixed expected digests; preserve historical bytes." + }, + { + "path": "scripts/observation-2.2-material.mjs", + "before": null, + "after": "d963d6affcc45878b6007c2b5fe052e76a08218019ce720b0db1cea4e77ca56b", + "rationale": "Independently construct 2.2 projection preimages without production indexing or canonicalization." + }, { "path": "scripts/run-current-tests.mjs", "before": null, "after": "5161ce1e3092df97f187e1476f94ef2e8ad8ca2e411a01f8afa26b798b79c848", "rationale": "Windows reliability: retry the singleton watcher observation once only after its sole complete TAP latency failure." }, + { + "path": "scripts/run-retrieval-observation-qualification-2.2.mjs", + "before": null, + "after": "025cf0d8bd2855c4d044800ee75651e5f8207d986e02a21a3434af222e703c7d", + "rationale": "Separate 2.2 runner with strict source binding, complete manifest checks and native vector reuse verification; preserve workload and p95." + }, + { + "path": "scripts/run-retrieval-observation-qualification.mjs", + "before": "5001102ad0df3a89e27677fe100143353b8f7b1d", + "after": "0d4c8aa90b17fda486745ec28a24dec99f0c014952bbfffd223658e137f58f91", + "rationale": "Retain existing current qualification adaptation; add only PR #47 allowlisted diagnostic output." + }, { "path": "scripts/run-watcher-observation-qualification.mjs", "before": "a11d7081118cde88a8db2d8bbab6429805f61b2c", @@ -1043,11 +1091,17 @@ "after": "7dc4248c03f4a27f2799f020b20172b6e7cfa68cb3e6eb796fa18f01949216f9", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "test/retrieval-observation-2.2.test.mjs", + "before": null, + "after": "73d368b0d4791bab1eff8bfe0be839ff9b66c3c00b313e685bfcab01d5b49c47", + "rationale": "Test fixed pins, fail-closed oracle substitution, workflow separation, and actual native 10k SQLite convergence/reuse." + }, { "path": "test/retrieval-observation-qualification.test.mjs", "before": "4e4e0a64a7b03c61dcf933b7ffc4364d56eab0fd", - "after": "76080c78a278920fe08fab081d7eccb80395d8620361197e9d8b45cd6e231455", - "rationale": "Workflow equivalence and Q-GUARD repair: retain the approved v5 workflow digest and isolate the non-Linux observation oracle from an unrelated pull-request event environment." + "after": "456cb1355c6b60e70f27bbfba34e3b13de6713501943d6b1e1b2ac883e5a3e4e", + "rationale": "Retain exact historical workflow hash via immutable Git object; successor workflow contract is tested separately." }, { "path": "test/retrieval-provenance.test.mjs", diff --git a/docs/OBSERVATION-2.2-QUALIFICATION.md b/docs/OBSERVATION-2.2-QUALIFICATION.md new file mode 100644 index 0000000..0e726ac --- /dev/null +++ b/docs/OBSERVATION-2.2-QUALIFICATION.md @@ -0,0 +1,55 @@ +# Engine 2.2 observation qualification + +The 2.1.2 generator remains byte-identical to main at +`650eab4a6752227cae336d7556a57826c22a0d5a`. Its fixtures and earlier failures +remain historical evidence. The named historical workflow checks out +`d81f9d1351f1a9228650a840629191a92f2dfb22`, the exact Engine 2.1.2 implementation +from successful observation run 34022421978. Its event commit remains the actual +workflow event commit; a historical replay cannot certify a current candidate. + +The separate `observation-2.2.yml` workflow executes the current checkout with +`fetch-depth: 0`. The runner rejects shallow, dirty, mismatched-version and +mismatched-source checkouts. Receipts bind actual source commit, Engine 2.2.0, +retrieval contract, schema 2, fixture version and fixed fixture/sample-plan +digests. Nothing overrides the production Engine identity. + +`observation-2.2-material.mjs` builds expected projection preimages without the +production indexer, SQLite store, manifest builder or canonicalizer. It uses +the byte-checked historical corpus and a separate canonical serializer. +`generate-retrieval-observation-fixture-2.2.mjs` checks fixed pins before any +index operation; generated actual manifests cannot choose their expected +digests. Changes to those pins require source review, never runtime acceptance. +The configuration digest changes because it now truthfully binds Engine 2.2.0. + +The current runner checks complete expected manifests, 313 calls/10,000 items, +32-item batching, exact request sequence, active local embedding, one changed +content item and 9,999 reused vectors read back from SQLite. Ten source-bound +chunk records change because the modified source contains ten sections. +Incremental and clean rebuild manifests and query results must converge. +All five measured rounds must be deterministic. The original 50 samples, +10 warmups and strict p95 below 500,000 microseconds remain unchanged. +The deterministic provider is an in-process constant-vector local provider; +this lane does not establish real ONNX model quality or inference performance. + +The established performance qualification remains Linux x64 only. Native +Windows tests additionally execute the actual 10k SQLite indexing, reuse and +manifest-convergence path, without claiming the Linux performance receipt. +All retained observation artifacts are bounded JSON; source, database, query, +provider and result bytes remain outside the uploaded evidence directory. +Failure logs use allowlisted codes. An unwritable evidence directory remains +a failed run and cannot produce a successful receipt. + +Run from a clean full clone, with dependencies installed, and bundle outside +the checkout: + +```sh +npm ci +./node_modules/.bin/esbuild scripts/run-retrieval-observation-qualification-2.2.mjs --bundle --platform=node --format=esm --target=node24 --outfile=/tmp/observation-2.2.mjs +mkdir -m 700 /tmp/observation-2.2-receipts +node /tmp/observation-2.2.mjs --mode observation --artifact-root /tmp/observation-2.2-receipts +``` + +This lane is one gate in issue #44. It does not qualify managed-MOC durable +no-op receipts, native crash recovery, 24-hour soak, downstream compatibility, +or publication. External implementation/review responses remain pending and +are not endorsements or conformance evidence. diff --git a/evidence/2026-09-08-release-220-starting-state.md b/evidence/2026-09-08-release-220-starting-state.md new file mode 100644 index 0000000..db9d063 --- /dev/null +++ b/evidence/2026-09-08-release-220-starting-state.md @@ -0,0 +1,61 @@ +# Engine 2.2.0 qualification starting state + +Owner authorization: release 2.2.0 only after all mandatory qualification, +review and exact-commit checks pass. No tag or publication is authorized by +a partial result. Starting main: `650eab4a6752227cae336d7556a57826c22a0d5a`. + +Local original checkout: `2fbd4ec68ec825b09e5194c9878a7ae90a281392`, branch +`feature/navigation-effects-contract-v1`, substantial unrelated tracked and +untracked work. Preserved. Qualification uses a separate full clone. +Host: native Windows 11 Pro, 10.0.26200, x64; Node v24.18.0, npm 10.9.4. +GitHub CLI 2.97.0. Git reports `is-shallow-repository=false`. +The fresh clone initially inherited CRLF conversion; before qualification, +unchanged files were restored to exact committed bytes and local +`core.autocrlf=false` was configured. Frozen fixture assertions caught this +environment defect; their byte comparisons were retained. + +Open PR inventory on September 8: + +| PR | State and disposition | +| --- | --- | +| #47 | Draft, head `2c10df669233aff7353557de4c5931b8682cb951`; exact three-file diff reviewed and incorporated in the qualification branch. Original PR stays open until green integration. Missing resealed candidate inventory caused current-runtime/CI failure; historical replay passed. | +| #46 | EU AI evidence planning; conflicting, outside release repair scope; left open. | +| #38 | Older hosted CI/current-runtime repair draft; left open pending exact supersession assessment. | +| #30 | Watcher recovery historical work; left open; historical evidence must not be rewritten. | +| #29 | Retrieval evaluation historical work; left open. | +| #27 | Lineage citations/temporal search historical work; left open. | +| #26 | Hybrid retrieval historical work; left open. | + +Open issues: #44 controls release qualification and remains open; #36 tracks +settings/discovery runtime ownership and remains open. #44 comments confirm +PR #43 merged and retain explicit gaps in durable no-op receipts, performance, +native durability, soak and consumer acceptance. Existing source passes are +not release qualification. + +Latest successful main CI: 34063066179; runtime qualification: 34063066239, +both at starting main on September 6. Last successful Phase 4 observation: +34022421978 at `d81f9d1351f1a9228650a840629191a92f2dfb22`. +Scheduled failures: 34105302635 (September 7), 34206749198 (September 8), +both at starting main. Latest PR #47 CI failure: 34231007132; runtime failure: +34231007131. Failure evidence is retained; no performance regression follows +from a projection identity mismatch. + +Remote newest version tag: v2.1.2 resolves to +`7bf14b481e78c5ae9d1e14661602be4f24559d0e`. GitHub latest release: v2.1.1. +No v2.2.0 tag/release exists. npm public registry reports versions 1.3.0, +2.0.0 and 2.0.1; latest=2.0.1; maintainer `odenknight`; repository correct. +`npm whoami --registry https://registry.npmjs.org` failed E401. This does not +prove local account ownership or trusted-publisher configuration. +GitHub environment list and ruleset list are empty; classic main protection +returns 404 (branch not protected). Publication remains blocked until the +approved publishing mechanism and owner controls are established. + +Observatory exists as a private repository and local integration checkout. +No production vault or Observatory deployment has been changed. +Raw starting PR, issue, run, tag, release and npm snapshots are retained in the +external local qualification evidence directory, outside the package checkout. + +Initial `npm ci` succeeded (13 installed packages, zero audit findings). +Qualification commands and receipts are recorded separately for each actual +candidate. No released SHA, release tarball, signature, registry verification, +24-hour soak or Kosmos integration pass is claimed by this starting record. diff --git a/scripts/generate-retrieval-observation-fixture-2.2.mjs b/scripts/generate-retrieval-observation-fixture-2.2.mjs new file mode 100644 index 0000000..4b530f9 --- /dev/null +++ b/scripts/generate-retrieval-observation-fixture-2.2.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { observation22Material } from './observation-2.2-material.mjs'; +import { expectedPerformanceCoordinates as historicalCoordinates } from './generate-retrieval-observation-fixture.mjs'; +export { + buildPerformanceCorpus, indexRequestSequenceDigest, performanceQueryCycle, + queryAttemptSetDigest, resultSetDigest, sampleVectorDigest, + PERFORMANCE_POLICY_DIGEST, PERFORMANCE_EVALUATION_DIGEST, PERFORMANCE_VAULT_ID, +} from './generate-retrieval-observation-fixture.mjs'; + +export const PERFORMANCE_GENERATOR_VERSION = 'gkos-retrieval-evaluation-performance-generator/2.2.0'; +export const PERFORMANCE_FIXTURE_VERSION = 'gkos-retrieval-evaluation-performance-fixture/2.2.0'; +export const PERFORMANCE_SAMPLE_PLAN_VERSION = 'gkos-retrieval-evaluation-performance-sample-plan/2.2.0'; +export const PERFORMANCE_FIXTURE_DIGEST = 'sha256:77a44ecebbe910d5ad0b5f96586f26b4519a23ab1b25e2e63650bd7b2e226134'; +export const PERFORMANCE_CONFIGURATION_DIGEST = 'sha256:4a10839a307698bc010d55b6a05fce087970258e9e219cc5207c2f77fb4be9b0'; +export const PERFORMANCE_SAMPLE_PLAN_DIGEST = 'sha256:fc8f3069d7161b7a31435f494f32693263b37504a94e0064adf277604d4c9f9b'; +export const PINS = Object.freeze({ + fixture_digest: PERFORMANCE_FIXTURE_DIGEST, + configuration_digest: PERFORMANCE_CONFIGURATION_DIGEST, + sample_plan_digest: PERFORMANCE_SAMPLE_PLAN_DIGEST, + initial_projection_digest: 'sha256:07115aadce8907fbb5829bc7ec6927c458f4a7df0facd6d9f85a711c84c97cec', + updated_projection_digest: 'sha256:f325bf421c63be624c5c33bdba9d5d98f62f76c996d1b529d2558ab5345ccb17', +}); +// A changed implementation cannot choose its own expected digest. Every oracle +// preimage must reproduce these reviewed constants before indexing can begin. +let material; +function checked() { + if (!material) { + const candidate = observation22Material(); + assert.deepEqual(candidate.pins, PINS, 'OBS_FIXTURE_INVALID'); + material = candidate; + } + return structuredClone(material); +} +export const performanceFixtureMaterial = () => checked().fixture; +export const performanceSamplePlan = () => ({ ...checked().plan, sample_plan_digest: PERFORMANCE_SAMPLE_PLAN_DIGEST }); +export const expectedPerformanceManifests = () => checked().manifests; +export const expectedPerformanceCoordinates = () => ({ ...historicalCoordinates(), index: checked().plan.indexing }); diff --git a/scripts/observation-2.2-material.mjs b/scripts/observation-2.2-material.mjs new file mode 100644 index 0000000..f892bd5 --- /dev/null +++ b/scripts/observation-2.2-material.mjs @@ -0,0 +1,56 @@ +// Qualification oracle: builds preimages without importing the indexer, store, +// manifest implementation, or production canonicalizer. Pins live separately. +import { createHash } from 'node:crypto'; +import * as historical from './generate-retrieval-observation-fixture.mjs'; + +export function canonical(value) { + if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'; + if (value !== null && typeof value === 'object') return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}'; + return JSON.stringify(value); +} +export const digest = value => 'sha256:' + createHash('sha256').update(canonical(value)).digest('hex'); + +export function observation22Material() { + const plan = historical.performanceSamplePlan(); + delete plan.sample_plan_digest; + const fixture = historical.performanceFixtureMaterial(); + fixture.contract_version = 'gkos-retrieval-evaluation-performance-fixture/2.2.0'; + fixture.generator_contract_version = 'gkos-retrieval-evaluation-performance-generator/2.2.0'; + fixture.engine_version = '2.2.0'; + fixture.projection_schema_version = 2; + plan.contract_version = 'gkos-retrieval-evaluation-performance-sample-plan/2.2.0'; + plan.fixture.fixture_contract_version = fixture.contract_version; + plan.fixture.generator_contract_version = fixture.generator_contract_version; + plan.fixture.fixture_digest = digest(fixture); + plan.indexing.engine_version = '2.2.0'; + plan.indexing.configuration_preimage.engine_version = '2.2.0'; + plan.indexing.configuration_digest = digest(plan.indexing.configuration_preimage); + const manifests = {}; + for (const updated of [false, true]) { + const corpus = historical.buildPerformanceCorpus(updated); + const chunks = [...corpus.chunks].sort((a, b) => a.chunk_id < b.chunk_id ? -1 : a.chunk_id > b.chunk_id ? 1 : 0); + const base = { + contract_version: 'gkos-retrieval/1.0.0-draft.1', projection_schema_version: 2, + engine_version: '2.2.0', vault_id: plan.indexing.vault_id, + source_snapshot_digest: corpus.source_snapshot_digest, + configuration_digest: plan.indexing.configuration_digest, policy_digest: plan.indexing.policy_digest, + chunker_version: 'gkos-heading-chunker/1', tokenizer_version: 'gkos-ascii-whitespace/1', + lexical_backend: 'sqlite_fts5', embedding_provider_id: 'phase4-observation-local', + embedding_model_id: 'phase4-observation-constant-v1', embedding_dimensions: 4, + source_count: 1000, chunk_count: 10000, + }; + const projection_digest = digest({ ...base, chunks, vectors: chunks.map(chunk => ({ chunk_id: chunk.chunk_id, vector: [1, 0, 0, 0] })) }); + manifests[updated ? 'updated' : 'initial'] = { ...base, projection_id: 'retrieval:' + projection_digest.slice(7, 31), projection_digest }; + } + for (const phase of ['initial', 'incremental_update', 'clean_rebuild']) { + const manifest = manifests[phase === 'initial' ? 'initial' : 'updated']; + plan.indexing[phase].expected_projection_id = manifest.projection_id; + plan.indexing[phase].expected_projection_digest = manifest.projection_digest; + } + plan.indexing.incremental_update.prior_projection_digest = manifests.initial.projection_digest; + return { fixture, plan, manifests, pins: { + fixture_digest: digest(fixture), configuration_digest: plan.indexing.configuration_digest, + sample_plan_digest: digest(plan), initial_projection_digest: manifests.initial.projection_digest, + updated_projection_digest: manifests.updated.projection_digest, + } }; +} diff --git a/scripts/run-retrieval-observation-qualification-2.2.mjs b/scripts/run-retrieval-observation-qualification-2.2.mjs new file mode 100644 index 0000000..7595c5e --- /dev/null +++ b/scripts/run-retrieval-observation-qualification-2.2.mjs @@ -0,0 +1,1685 @@ +/** + * Private Phase-4 Slice-C qualification runner. + * + * This file is bundled with esbuild before execution so it can consume the + * repository-private coordinator observer seam without adding a package + * export. It emits only bounded canonical JSON receipts; generated source, + * SQLite, provider, query, and result bytes never enter the artifact root. + */ +import { createHash, randomBytes } from "node:crypto"; +import { createRequire } from "node:module"; +import { arch, platform, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + chmod, + lstat, + mkdir, + open, + readFile, + rm, + rmdir, +} from "node:fs/promises"; +import { execFileSync, spawn } from "node:child_process"; +import { ENGINE_VERSION } from "../src/version.ts"; + +import { + coordinatorFromRetrievalEvaluationDatabase, + indexRetrievalGeneration, +} from "../src/retrieval/coordinator.ts"; +import { detectSqliteLexicalCapability } from "../src/retrieval/sqlite-store.ts"; +import { sealRetrievalEvaluationObservationReport } from "../src/retrieval/evaluation.ts"; +import { retrievalCanonicalDigest, retrievalSha256, stableJson } from "../src/retrieval/digest.ts"; +import { canonicalPath, canonicalPathContains, sameCanonicalPath } from "../src/retrieval/path-security.ts"; +import { + PERFORMANCE_CONFIGURATION_DIGEST, + PERFORMANCE_EVALUATION_DIGEST, + PERFORMANCE_FIXTURE_DIGEST, + PERFORMANCE_GENERATOR_VERSION, + PERFORMANCE_POLICY_DIGEST, + PERFORMANCE_SAMPLE_PLAN_DIGEST, + PERFORMANCE_SAMPLE_PLAN_VERSION, + PERFORMANCE_VAULT_ID, + buildPerformanceCorpus, + expectedPerformanceCoordinates, + expectedPerformanceManifests, + PERFORMANCE_FIXTURE_VERSION, + indexRequestSequenceDigest, + performanceFixtureMaterial, + performanceQueryCycle, + performanceSamplePlan, + queryAttemptSetDigest, + resultSetDigest, + sampleVectorDigest, +} from "./generate-retrieval-observation-fixture-2.2.mjs"; + +const OBSERVATION_RECEIPT_VERSION = "gkos-retrieval-evaluation-phase4-observation-receipt/2.2.0"; +const CLI_RECEIPT_VERSION = "gkos-retrieval-evaluation-phase4-qualification/1.0.0"; +const CLI_SAMPLE_PLAN_VERSION = "gkos-retrieval-evaluation-phase4-qualification-sample-plan/1.0.0"; +const CLI_SAMPLE_PLAN_DIGEST = "sha256:b37749ee2302fa5086769aa81234f89a4b180e7f2569a18d19c86178da8fb83d"; +const PACK_MANIFEST_DIGEST = "sha256:6732519a4912714a432680c88219322c80413e4165b5e3f613f23e82cd7ee340"; +const SLICE_A_PACK_COMMIT = "cac029a5b570135b26f3585bc86f4c9beb00c36d"; +const PHASE3_BASE_COMMIT = "5396d46d"; +const SLICE_B_EVIDENCE_COMMIT = "ed3a7552b1d4a705c1b1a722b07255e89ec42186"; +const SLICE_B_PROTECTED_PATH_COUNT = 112; +const SLICE_B_PROTECTED_PATH_INVENTORY_DIGEST = "sha256:f88846fdaf91e59f3e80780b787340b82e5a7177c474518aa901f63046c9478f"; +const SLICE_B_AUTHORIZED_ADDITION_PATHS = Object.freeze([ + "bin/gkos.mjs", + "src/ingest/source-scan.ts", + "src/watcher/cli.ts", + "src/watcher/contracts.ts", + "src/watcher/coordinator.ts", + "src/watcher/fs-authority.ts", + "src/watcher/host.ts", + "src/watcher/index-validation-hook.ts", + "src/watcher/journal.ts", + "src/watcher/pointer.ts", + "src/watcher/removal-adapter.ts", + "src/watcher/service.ts", +]); +const SLICE_B_AUTHORIZED_ADDITION_INVENTORY_DIGEST = "sha256:a812a6378310da741ed009d3123498050794c4d7ff5f1e1d305ed10b0175fa54"; +const PHASE5_SLICE_B_BASE_COMMIT = "6e9346c7e749b5288ff3680766b34a038e816d18"; +const PHASE5_SLICE_B_QUALIFIED_HEAD = "7b5262baee9fcda23d50b0cee0c4977d6e4305e7"; +const PHASE5_SLICE_B_EXPECTED_CHANGE_ROWS = Object.freeze([ + ["M", ".gitattributes"], + ["M", ".github/workflows/ci.yml"], + ["A", "bin/gkos.mjs"], + ["M", "bin/gkx.mjs"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/TECHNICAL_README.md"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/conformance.schema.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/journal.schema.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/pack-manifest.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/sample-plan.schema.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/source-removal.schema.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/watcher-cli-fixture.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/watcher-conformance-fixture.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/watcher-recovery-fixture.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/watcher-sample-plan.json"], + ["M", "contracts/watcher/gkos-watcher-recovery-1.0.0-draft.1/watcher-storage-fixture.json"], + ["A", "docs/phase5-watcher-host.md"], + ["M", "package-lock.json"], + ["M", "package.json"], + ["M", "scripts/build.mjs"], + ["M", "scripts/generate-watcher-recovery-source-bundle.mjs"], + ["M", "scripts/run-retrieval-observation-qualification.mjs"], + ["A", "scripts/run-watcher-observation-qualification.mjs"], + ["M", "src/desktop-agent.ts"], + ["M", "src/incremental.ts"], + ["A", "src/ingest/source-scan.ts"], + ["M", "src/ingest/storage.ts"], + ["M", "src/ingest/validation.ts"], + ["M", "src/retrieval/gkx-provenance.ts"], + ["M", "src/retrieval/state-writer-lock.ts"], + ["A", "src/watcher/cli.ts"], + ["M", "src/watcher/contracts.ts"], + ["A", "src/watcher/coordinator.ts"], + ["A", "src/watcher/fs-authority.ts"], + ["A", "src/watcher/host.ts"], + ["A", "src/watcher/index-validation-hook.ts"], + ["A", "src/watcher/journal.ts"], + ["A", "src/watcher/pointer.ts"], + ["A", "src/watcher/removal-adapter.ts"], + ["A", "src/watcher/service.ts"], + ["M", "test/retrieval-observation-qualification.test.mjs"], + ["A", "test/watcher-coordinator.test.mjs"], + ["A", "test/watcher-index-validation.test.mjs"], + ["A", "test/watcher-journal-host.test.mjs"], + ["A", "test/watcher-observation-qualification.test.mjs"], + ["A", "test/watcher-pointer-host.test.mjs"], + ["M", "test/watcher-recovery-contracts.test.mjs"], + ["A", "test/watcher-service-cli.test.mjs"], + ["A", "test/watcher-source-scan.test.mjs"], +]); +const SCAN_PRESENTATION_VERSION = "gkos-retrieval-evaluation-scan-presentation/1.0.0-draft.1"; +const QUERY_REQUEST_SEQUENCE_VERSION = "gkos-retrieval-evaluation-performance-query-request-sequence/1.0.0"; +const OBSERVATION_RUNNER_PATH = "scripts/run-retrieval-observation-qualification-2.2.mjs"; +const OBSERVATION_PLAN_FILE = "performance-sample-plan.json"; +const OBSERVATION_RECEIPT_FILE = "observation-receipt.json"; +const OBSERVATION_REPORT_FILE = "observation-report.json"; +const CLI_RECEIPT_FILE = "gkos-phase4-retrieval-qualification.json"; +const MAX_SAFE = Number.MAX_SAFE_INTEGER; +const require = createRequire(import.meta.url); + +const CLI_FAILURE_CODES = new Set([ + "QUAL_PACK_INVALID", "QUAL_CLI_FIXTURE_INVALID", "QUAL_ENVIRONMENT_INVALID", + "QUAL_CLI_PROCESS_FAILED", "QUAL_CLI_TAP_INVALID", "QUAL_CLI_TEST_TOTAL_INVALID", + "QUAL_TEMPORAL_TEST_MISSING", "QUAL_TUNE_TEST_MISSING", "QUAL_EVAL_BUDGET_EXCEEDED", + "QUAL_TUNE_BUDGET_EXCEEDED", "QUAL_CLI_WALL_BUDGET_EXCEEDED", + "QUAL_WINDOWS_PROCESS_FAILED", "QUAL_WINDOWS_TAP_INVALID", "QUAL_WINDOWS_TEST_TOTAL_INVALID", + "QUAL_WINDOWS_WALL_BUDGET_EXCEEDED", +]); + +const OBSERVATION_FAILURE_CODES = new Set([ + "OBS_SOURCE_PROVENANCE_INVALID", "OBS_PACK_IMMUTABILITY_INVALID", "OBS_FTS5_UNAVAILABLE", + "OBS_FIXTURE_INVALID", "OBS_INDEX_FAILED", "OBS_INDEX_PROVIDER_LEDGER_INVALID", + "OBS_UPDATE_FAILED", "OBS_UPDATE_REUSE_INVALID", "OBS_QUERY_FAILED", "OBS_QUERY_SAMPLE_INVALID", + "OBS_QUERY_P95_EXCEEDED", "OBS_REBUILD_FAILED", "OBS_CONVERGENCE_INVALID", + "OBS_NETWORK_ATTEMPTED", "OBS_REPORT_INVALID", +]); + +const CLI_SAMPLE_PLAN = Object.freeze({ + contract_version: CLI_SAMPLE_PLAN_VERSION, + warmup_count: 0, + sample_count: 1, + test_concurrency: 1, + cli_test_files: ["test/retrieval-evaluation-cli.test.mjs"], + cli_expected_test_count: 23, + eval_test_name: "actual coordinator eval replay emits exact text and pretty canonical JSON offline", + tune_test_name: "actual exhaustive tune replay publishes one durable exact candidate and no sidecars", + windows_security_test_files: [ + "test/retrieval-windows-path-security.test.mjs", + "test/retrieval-config.test.mjs", + "test/retrieval-store.test.mjs", + ], + windows_security_expected_test_count: 49, + thresholds_micros: { + eval_test: 90_000_000, + tune_test: 300_000_000, + cli_wall: 600_000_000, + windows_security_wall: 180_000_000, + }, + sample_plan_digest: CLI_SAMPLE_PLAN_DIGEST, +}); + +function fail(code) { + const error = new Error(code); + error.code = code; + throw error; +} + +function assertExact(actual, expected, code) { + if (stableJson(actual) !== stableJson(expected)) fail(code); +} + +function sha256Bytes(bytes) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function prettyCanonical(value) { + return `${JSON.stringify(JSON.parse(stableJson(value)), null, 2)}\n`; +} + +function elapsedMicros(start) { + const delta = process.hrtime.bigint() - start; + const value = (delta + 999n) / 1_000n; + if (value < 0n || value > BigInt(MAX_SAFE)) fail("GKX_EVAL_OBSERVATION_TIMER_INVALID"); + return Number(value); +} + +export function decimalMillisToCeilMicrosForTest(value) { + if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/u.test(value)) { + fail("GKX_EVAL_QUALIFICATION_DURATION_INVALID"); + } + const [whole, fraction = ""] = value.split("."); + const wholeMicros = BigInt(whole) * 1_000n; + let fractionMicros = 0n; + if (fraction !== "") { + const numerator = BigInt(fraction) * 1_000n; + const denominator = 10n ** BigInt(fraction.length); + fractionMicros = (numerator + denominator - 1n) / denominator; + } + const result = wholeMicros + fractionMicros; + if (result > BigInt(MAX_SAFE)) fail("GKX_EVAL_QUALIFICATION_DURATION_INVALID"); + return Number(result); +} + +function sortedFailureCodes(codes, allowed) { + const values = [...new Set(codes)]; + if (values.some((code) => !allowed.has(code))) fail("GKX_EVAL_QUALIFICATION_FAILURE_CODE_INVALID"); + return values.sort((left, right) => left < right ? -1 : left > right ? 1 : 0); +} + +function observationSamplePlanReceipt() { + return Object.freeze({ + contract_version: PERFORMANCE_SAMPLE_PLAN_VERSION, + sample_plan_digest: PERFORMANCE_SAMPLE_PLAN_DIGEST, + warmup_count: 10, + sample_count: 50, + p95_strict_upper_bound_micros: 500_000, + }); +} + +export function buildObservationReceiptForTest(value) { + const failureCodes = sortedFailureCodes(value.failure_codes, OBSERVATION_FAILURE_CODES); + const status = failureCodes.length === 0 ? "pass" : "fail"; + if (status === "pass" && [value.fixture, value.environment, value.indexing, value.query_latency, + value.convergence, value.observation_report].some((child) => child === null)) { + fail("GKX_EVAL_OBSERVATION_RECEIPT_NULLABILITY_INVALID"); + } + if (status === "fail" && (value.publication_eligible !== false || value.observation_report !== null)) { + fail("GKX_EVAL_OBSERVATION_RECEIPT_NULLABILITY_INVALID"); + } + const material = { + contract_version: OBSERVATION_RECEIPT_VERSION, + status, + failure_codes: failureCodes, + publication_eligible: value.publication_eligible, + source: value.source, + identity: { + engine_version: ENGINE_VERSION, + retrieval_contract_version: "gkos-retrieval/1.0.0-draft.1", + projection_schema_version: 2, + fixture_version: PERFORMANCE_FIXTURE_VERSION, + fixture_digest: PERFORMANCE_FIXTURE_DIGEST, + source_commit: value.source?.checkout_commit ?? null, + }, + sample_plan: observationSamplePlanReceipt(), + fixture: value.fixture, + environment: value.environment, + indexing: value.indexing, + query_latency: value.query_latency, + convergence: value.convergence, + observation_report: value.observation_report, + }; + return Object.freeze({ ...material, receipt_digest: retrievalCanonicalDigest(material) }); +} + +function parseArgs(argv) { + const parsed = { mode: null, artifact_root: null }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--mode" && index + 1 < argv.length) parsed.mode = argv[++index]; + else if (token === "--artifact-root" && index + 1 < argv.length) parsed.artifact_root = argv[++index]; + else fail("GKX_EVAL_QUALIFICATION_ARGUMENTS_INVALID"); + } + if (!["observation", "cli", "windows-security", "immutability", "offline-self-test", "plan"].includes(parsed.mode)) { + fail("GKX_EVAL_QUALIFICATION_ARGUMENTS_INVALID"); + } + if (["observation", "cli", "windows-security"].includes(parsed.mode) && !parsed.artifact_root) { + fail("GKX_EVAL_QUALIFICATION_ARGUMENTS_INVALID"); + } + return parsed; +} + +function git(repoRoot, args, optional = false) { + try { + return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", optional ? "ignore" : "pipe"] }).trim(); + } catch (error) { + if (optional) return null; + throw error; + } +} + +const LOWERCASE_COMMIT_RE = /^[0-9a-f]{40}$/u; + +export function resolveSourceHeadCommitForTest(checkoutCommit, suppliedSourceHeadCommit) { + if (typeof checkoutCommit !== "string" || !LOWERCASE_COMMIT_RE.test(checkoutCommit)) { + fail("GKX_EVAL_SOURCE_COMMIT_INVALID"); + } + const sourceHeadCommit = suppliedSourceHeadCommit === undefined ? checkoutCommit : suppliedSourceHeadCommit; + if (typeof sourceHeadCommit !== "string" || !LOWERCASE_COMMIT_RE.test(sourceHeadCommit)) { + fail("GKX_EVAL_SOURCE_COMMIT_INVALID"); + } + return sourceHeadCommit; +} + +async function sourceState(repoRoot) { + const checkoutCommit = git(repoRoot, ["rev-parse", "HEAD"]); + const status = git(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); + const runnerBytes = await readFile(join(repoRoot, OBSERVATION_RUNNER_PATH)); + const runnerFileSha256 = sha256Bytes(runnerBytes); + const committedObject = git(repoRoot, ["rev-parse", `HEAD:${OBSERVATION_RUNNER_PATH}`], true); + let runnerCommittedBlobSha256 = null; + let committedAtCheckout = false; + if (committedObject !== null) { + const committedBytes = execFileSync("git", ["show", `HEAD:${OBSERVATION_RUNNER_PATH}`], { cwd: repoRoot }); + runnerCommittedBlobSha256 = sha256Bytes(committedBytes); + committedAtCheckout = Buffer.compare(runnerBytes, committedBytes) === 0; + } + const sourceHeadCommit = resolveSourceHeadCommitForTest( + checkoutCommit, + process.env.GKOS_PHASE4_SOURCE_HEAD_COMMIT, + ); + const eventCommit = process.env.GITHUB_SHA ?? checkoutCommit; + if (!LOWERCASE_COMMIT_RE.test(eventCommit)) fail("GKX_EVAL_SOURCE_COMMIT_INVALID"); + const worktreeClean = status === ""; + const executionProvenance = worktreeClean && committedAtCheckout ? "committed_clean" : "local_uncommitted"; + return Object.freeze({ + checkoutCommit, + sourceHeadCommit, + eventCommit, + runnerFileSha256, + runnerCommittedBlobSha256, + committedAtCheckout, + worktreeClean, + executionProvenance, + }); +} + +function eventName(allowed) { + const value = process.env.GITHUB_ACTIONS === "true" ? process.env.GITHUB_EVENT_NAME : "local"; + if (typeof value !== "string" || !allowed.has(value)) fail("GKX_EVAL_SOURCE_EVENT_INVALID"); + return value; +} + +async function qualificationSourceReceipt(repoRoot) { + const state = await sourceState(repoRoot); + return Object.freeze({ + checkout_commit: state.checkoutCommit, + source_head_commit: state.sourceHeadCommit, + event_commit: state.eventCommit, + event_name: eventName(new Set(["local", "push", "pull_request", "workflow_dispatch"])), + phase4_slice_b_evidence_commit: SLICE_B_EVIDENCE_COMMIT, + }); +} + +async function observationSourceReceipt(repoRoot) { + const state = await sourceState(repoRoot); + return Object.freeze({ + checkout_commit: state.checkoutCommit, + source_head_commit: state.sourceHeadCommit, + event_commit: state.eventCommit, + event_name: eventName(new Set(["local", "push", "pull_request", "schedule", "workflow_dispatch"])), + runner_file_sha256: state.runnerFileSha256, + runner_committed_blob_sha256: state.runnerCommittedBlobSha256, + runner_committed_at_checkout: state.committedAtCheckout, + worktree_clean: state.worktreeClean, + execution_provenance: state.executionProvenance, + }); +} + +export function publicationEligibleForTest(source) { + return process.env.GITHUB_ACTIONS === "true" && + ["push", "pull_request", "schedule", "workflow_dispatch"].includes(source.event_name) && + source.execution_provenance === "committed_clean" && source.worktree_clean && source.runner_committed_at_checkout && + source.runner_committed_blob_sha256 === source.runner_file_sha256 && + source.checkout_commit === source.source_head_commit && source.source_head_commit === source.event_commit; +} + +function gitDiffClean(repoRoot, commit, paths) { + try { + execFileSync("git", ["diff", "--quiet", "--no-renames", commit, "--", ...paths], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["diff", "--cached", "--quiet", "--no-renames", commit, "--", ...paths], { cwd: repoRoot, stdio: "ignore" }); + return true; + } catch { return false; } +} + +function splitLines(value) { + return value === "" ? [] : value.split(/\r?\n/u).filter((row) => row !== ""); +} + +function codeUnitSortedUniquePaths(paths) { + const sorted = [...paths].sort((left, right) => left < right ? -1 : left > right ? 1 : 0); + if (sorted.some((path, index) => path === "" || path.includes("\0") || (index > 0 && path === sorted[index - 1]))) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + return sorted; +} + +function gitNulPathInventory(repoRoot, args) { + let bytes; + try { + bytes = execFileSync("git", args, { cwd: repoRoot, stdio: ["ignore", "pipe", "ignore"] }); + } catch { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + const text = bytes.toString("utf8"); + if (!Buffer.from(text, "utf8").equals(bytes) || (text !== "" && !text.endsWith("\0"))) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + return codeUnitSortedUniquePaths(text === "" ? [] : text.slice(0, -1).split("\0")); +} + +function pathInventoryDigest(paths) { + return sha256Bytes(Buffer.from(`${paths.join("\n")}\n`, "utf8")); +} + +function gitNulNameStatus(repoRoot, args) { + let bytes; + try { + bytes = execFileSync("git", args, { cwd: repoRoot, stdio: ["ignore", "pipe", "ignore"] }); + } catch { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + const text = bytes.toString("utf8"); + if (!Buffer.from(text, "utf8").equals(bytes) || (text !== "" && !text.endsWith("\0"))) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + const fields = text === "" ? [] : text.slice(0, -1).split("\0"); + if (fields.length % 2 !== 0) fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + const rows = []; + for (let index = 0; index < fields.length; index += 2) { + const status = fields[index]; + const path = fields[index + 1]; + if (!new Set(["A", "M"]).has(status) || path === "" || path.includes("\0")) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + rows.push([status, path]); + } + rows.sort((left, right) => left[1] < right[1] ? -1 : left[1] > right[1] ? 1 : 0); + if (rows.some((row, index) => index > 0 && row[1] === rows[index - 1][1])) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + return rows; +} + +function sameRows(left, right) { + return left.length === right.length && left.every((row, index) => + row.length === 2 && row[0] === right[index]?.[0] && row[1] === right[index]?.[1]); +} + +function verifySliceBProtectedInputs(repoRoot, headCommitInput, checkoutBound) { + if (typeof checkoutBound !== "boolean") fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + const protectedRoots = ["src", "bin"]; + const explicitProtectedPaths = [ + "package.json", "package-lock.json", + "test/retrieval-evaluation-cli.test.mjs", "test/fixtures/retrieval-evaluation-cli-phase4.json", + ]; + let headCommit; + try { + headCommit = execFileSync("git", ["rev-parse", "--verify", `${headCommitInput}^{commit}`], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + if (!/^[0-9a-f]{40}$/u.test(headCommit)) fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + if (!checkoutBound) { + try { + const checkoutHead = execFileSync("git", ["rev-parse", "--verify", "HEAD^{commit}"], { + cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + }).trim(); + execFileSync("git", ["merge-base", "--is-ancestor", headCommit, checkoutHead], { + cwd: repoRoot, stdio: "ignore", + }); + } catch { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + } + const baselineRootPaths = gitNulPathInventory(repoRoot, [ + "ls-tree", "-r", "--name-only", "-z", SLICE_B_EVIDENCE_COMMIT, "--", ...protectedRoots, + ]); + const baselineProtectedPaths = codeUnitSortedUniquePaths([...baselineRootPaths, ...explicitProtectedPaths]); + const currentRootPaths = gitNulPathInventory(repoRoot, ["ls-tree", "-r", "--name-only", "-z", headCommit, "--", ...protectedRoots]); + const baselineRootSet = new Set(baselineRootPaths); + const currentRootSet = new Set(currentRootPaths); + const authorizedAdditions = currentRootPaths.filter((path) => !baselineRootSet.has(path)); + const baseAllPaths = gitNulPathInventory(repoRoot, ["ls-tree", "-r", "--name-only", "-z", PHASE5_SLICE_B_BASE_COMMIT]); + const currentAllPaths = gitNulPathInventory(repoRoot, ["ls-tree", "-r", "--name-only", "-z", headCommit]); + const comparedPaths = codeUnitSortedUniquePaths([...new Set([...baseAllPaths, ...currentAllPaths])]); + const committedRows = gitNulNameStatus(repoRoot, [ + "diff", "--name-status", "--no-renames", "-z", PHASE5_SLICE_B_BASE_COMMIT, headCommit, "--", ...comparedPaths, + ]); + const indexPaths = checkoutBound ? gitNulPathInventory(repoRoot, ["ls-files", "-z"]) : []; + const untrackedPaths = checkoutBound ? gitNulPathInventory(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"]) : []; + if (baselineProtectedPaths.length !== SLICE_B_PROTECTED_PATH_COUNT || + pathInventoryDigest(baselineProtectedPaths) !== SLICE_B_PROTECTED_PATH_INVENTORY_DIGEST || + baselineRootPaths.some((path) => !currentRootSet.has(path)) || + authorizedAdditions.length !== SLICE_B_AUTHORIZED_ADDITION_PATHS.length || + authorizedAdditions.some((path, index) => path !== SLICE_B_AUTHORIZED_ADDITION_PATHS[index]) || + pathInventoryDigest(authorizedAdditions) !== SLICE_B_AUTHORIZED_ADDITION_INVENTORY_DIGEST || + !sameRows(committedRows, PHASE5_SLICE_B_EXPECTED_CHANGE_ROWS) || + (checkoutBound && (indexPaths.length !== currentAllPaths.length || + indexPaths.some((path, index) => path !== currentAllPaths[index]) || + untrackedPaths.length !== 0 || !gitDiffClean(repoRoot, headCommit, currentAllPaths)))) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + return Object.freeze({ + baseline_path_count: baselineProtectedPaths.length, + baseline_inventory_digest: SLICE_B_PROTECTED_PATH_INVENTORY_DIGEST, + authorized_addition_count: authorizedAdditions.length, + authorized_addition_inventory_digest: SLICE_B_AUTHORIZED_ADDITION_INVENTORY_DIGEST, + committed_change_count: committedRows.length, + phase5_slice_b_base_commit: PHASE5_SLICE_B_BASE_COMMIT, + source_head_commit: headCommit, + }); +} + +export function verifySliceBProtectedInputsForTest(repoRoot, headCommitInput = "HEAD") { + return verifySliceBProtectedInputs(repoRoot, headCommitInput, true); +} + +export async function verifyFrozenQualificationInputsForTest(repoRoot) { + if (!gitDiffClean(repoRoot, "650eab4a6752227cae336d7556a57826c22a0d5a", ["scripts/generate-retrieval-observation-fixture.mjs"])) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + const packRoot = "contracts/retrieval/gkos-retrieval-evaluation-1.0.0-draft.1"; + const phase03 = [ + "contracts/ingest/gkos-ingest-validation-1.0.0-draft.1", + "contracts/retrieval/gkos-retrieval-1.0.0-draft.1", + "contracts/retrieval/gkos-retrieval-1.0.0-draft.2", + "evidence/2026-08-20-functional-uplift-phase-0.md", + "evidence/2026-08-21-functional-uplift-phase-1.md", + "evidence/2026-08-21-functional-uplift-phase-2.md", + "evidence/2026-08-21-functional-uplift-phase-3.md", + ]; + // Slice B is a frozen historical input. Later, separately governed phases may + // add runtime surfaces, so validate the reviewed Phase-5 tree exactly and + // require it to be an ancestor instead of misclassifying every descendant + // checkout as a mutation of the historical qualification pack. + verifySliceBProtectedInputs(repoRoot, PHASE5_SLICE_B_QUALIFIED_HEAD, false); + if (!gitDiffClean(repoRoot, SLICE_A_PACK_COMMIT, [packRoot]) || + !gitDiffClean(repoRoot, PHASE3_BASE_COMMIT, phase03)) { + fail("GKX_EVAL_QUALIFICATION_IMMUTABILITY_INVALID"); + } + const packFiles = splitLines(git(repoRoot, ["ls-files", packRoot])); + const packUntracked = splitLines(git(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", packRoot])); + let packBytes = 0; + for (const path of packFiles) packBytes += (await readFile(join(repoRoot, path))).length; + if (packFiles.length !== 37 || packBytes !== 4_948_463 || packUntracked.length !== 0) { + fail("GKX_EVAL_QUALIFICATION_PACK_INVALID"); + } + + const cliFixtureBytes = await readFile(join(repoRoot, "test/fixtures/retrieval-evaluation-cli-phase4.json")); + const cliFixture = JSON.parse(cliFixtureBytes.toString("utf8")); + if (cliFixtureBytes.length !== 23_770 || + sha256Bytes(cliFixtureBytes) !== "sha256:fce5308d252d9e693244250543f6642af1cc4a7ef9404ac604313f6f37f107be" || + cliFixture.fixture_digest !== "sha256:958c06ed5b2d063e6b9530261ed74fd17bba5e599d6326aafe5bc7f1ac6c0ff6") { + fail("GKX_EVAL_QUALIFICATION_CLI_FIXTURE_INVALID"); + } + const conformance = JSON.parse((await readFile(join(repoRoot, packRoot, "conformance-fixture.json"))).toString("utf8")); + const reviewed = JSON.parse((await readFile(join(repoRoot, packRoot, "reviewed-bundle.json"))).toString("utf8")); + const expected = { + normalized_golden_digest: "sha256:f3de2536a3a6496aff6b4d6e7afca522cfd5e5b28b7b907a9b9e4d39ac1c8a9f", + source_corpus_digest: "sha256:1d99bb7d9c2522d71f7c2e2633517753098be6f2698586b248f18d99affc285d", + fixture_catalog_digest: "sha256:45addb4ab8b9634ffd22f2df099bc027a007130c11f109cb03c4e04ca38b5e16", + fixed_provider_digest: "sha256:7c28de4be4ad24a116d4f07d9b86ea9b38ab3298700ee1563f3b861026dd5b41", + environment_set_digest: "sha256:8269ad9e34b9704eaa724de4628d5667cb9ba4483ad08117a7a7549e202800c1", + baseline_digest: "sha256:0e46a9a83c55563ca33c41e98257455aca0c62ac46adb7b9b35c1abf6f3b9126", + metric_computation_fixture_digest: "sha256:6ea3a6b44d50efe60c2215a6bb30db60a5c29133474ddb0458c5ac4517c35e36", + projection_manifest_set_digest: "sha256:e7285d07af3027c290151f864b8e46e3b468bc89017b67b74af77970f621dea9", + result_origin_set_digest: "sha256:a5d357a9c236c37b86d79f099968724a5dc836db831d5506a1dda07c2877680c", + reviewed_bundle_digest: "sha256:2a49075651e2a4b19e813e59a3d4546cc602f0e86c56d1e048f94d917cd6df2a", + }; + for (const [key, value] of Object.entries(expected)) { + const observed = key === "normalized_golden_digest" ? conformance.golden?.expected_normalized?.golden_digest : reviewed[key]; + if (observed !== value) fail("GKX_EVAL_QUALIFICATION_PACK_INVALID"); + } + return Object.freeze({ + phase4_pack_file_count: 37, + phase4_pack_total_bytes: 4_948_463, + phase4_pack_manifest_digest: PACK_MANIFEST_DIGEST, + cli_fixture_byte_size: 23_770, + cli_fixture_raw_sha256: "sha256:fce5308d252d9e693244250543f6642af1cc4a7ef9404ac604313f6f37f107be", + cli_fixture_digest: "sha256:958c06ed5b2d063e6b9530261ed74fd17bba5e599d6326aafe5bc7f1ac6c0ff6", + normalized_golden_digest: expected.normalized_golden_digest, + source_corpus_digest: expected.source_corpus_digest, + fixture_catalog_digest: expected.fixture_catalog_digest, + fixed_provider_digest: expected.fixed_provider_digest, + environment_set_digest: expected.environment_set_digest, + baseline_digest: expected.baseline_digest, + metric_fixture_digest: expected.metric_computation_fixture_digest, + projection_manifest_set_digest: expected.projection_manifest_set_digest, + result_origin_set_digest: expected.result_origin_set_digest, + reviewed_bundle_digest: expected.reviewed_bundle_digest, + }); +} + +function qualificationEnvironment() { + const capability = detectSqliteLexicalCapability(); + const os = normalizedPlatform(); + const architecture = arch(); + if (!(["linux", "windows", "darwin"].includes(os)) || !(["x64", "arm64"].includes(architecture))) { + fail("GKX_EVAL_QUALIFICATION_ENVIRONMENT_INVALID"); + } + return Object.freeze({ + runner_class: process.env.GITHUB_ACTIONS === "true" ? "github_hosted" : "local", + runtime: "node", + runtime_version: process.versions.node, + os, + arch: architecture, + sqlite_version: capability.sqlite_version, + physical_fts5_available: capability.fts5_available, + scan_presentation_contract_version: SCAN_PRESENTATION_VERSION, + scan_presentation_fts5_available: true, + }); +} + +function posixMode(state) { + return Number(state.mode & 0o7777n); +} + +function sameDevice(left, right) { + return left === right || process.platform === "win32" && (left === 0n || right === 0n); +} + +function directoryIdentity(path, state) { + return Object.freeze({ + canonical_path: path, + device: state.dev, + inode: state.ino, + owner: state.uid, + mode: state.mode, + link_count: state.nlink, + }); +} + +function sameDirectoryIdentity(expected, state) { + return state.isDirectory() && !state.isSymbolicLink() && sameDevice(expected.device, state.dev) && + expected.inode === state.ino && expected.owner === state.uid && expected.mode === state.mode; +} + +async function sealPrivateDirectory(path, code) { + const canonical = await canonicalPath(path, { alias_error: code }); + const state = await lstat(canonical, { bigint: true }); + if (!state.isDirectory() || state.isSymbolicLink()) fail(code); + if (process.platform !== "win32") { + const euid = typeof process.geteuid === "function" ? BigInt(process.geteuid()) : -1n; + if (state.uid !== euid || posixMode(state) !== 0o700) fail(code); + } + return directoryIdentity(canonical, state); +} + +async function revalidateDirectory(identity, parent = null, code = "GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_CHANGED") { + const canonical = await canonicalPath(identity.canonical_path, { alias_error: code }); + const state = await lstat(identity.canonical_path, { bigint: true }); + if (!sameCanonicalPath(canonical, identity.canonical_path) || !sameDirectoryIdentity(identity, state)) fail(code); + if (parent && (!canonicalPathContains(parent.canonical_path, canonical) || dirname(canonical) !== parent.canonical_path)) fail(code); +} + +async function observationArtifactRoot(path) { + const identity = await sealPrivateDirectory(resolve(path), "GKX_EVAL_OBSERVATION_ARTIFACT_ROOT_INVALID"); + return Object.freeze({ + identity, + async write(name, value) { + await revalidateDirectory(identity, null, "GKX_EVAL_OBSERVATION_ARTIFACT_ROOT_CHANGED"); + if (basename(name) !== name || !/^(?:performance-sample-plan|observation-(?:receipt|report)|gkos-phase4-retrieval-qualification)\.json$/u.test(name)) { + fail("GKX_EVAL_OBSERVATION_ARTIFACT_NAME_INVALID"); + } + const path = join(identity.canonical_path, name); + const bytes = Buffer.from(prettyCanonical(value), "utf8"); + const handle = await open(path, "wx", 0o600); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + if (process.platform !== "win32") await chmod(path, 0o600); + const state = await lstat(path, { bigint: true }); + if (!state.isFile() || state.isSymbolicLink() || state.nlink !== 1n || BigInt(bytes.length) !== state.size || + process.platform !== "win32" && posixMode(state) !== 0o600) fail("GKX_EVAL_OBSERVATION_ARTIFACT_INVALID"); + const verified = await readFile(path); + if (Buffer.compare(bytes, verified) !== 0) fail("GKX_EVAL_OBSERVATION_ARTIFACT_CHANGED"); + await revalidateDirectory(identity, null, "GKX_EVAL_OBSERVATION_ARTIFACT_ROOT_CHANGED"); + }, + }); +} + +async function validateTempParent() { + const raw = tmpdir(); + const canonical = await canonicalPath(raw, { alias_error: "GKX_EVAL_OBSERVATION_TEMP_PARENT_INVALID" }); + const state = await lstat(canonical, { bigint: true }); + if (!state.isDirectory() || state.isSymbolicLink()) fail("GKX_EVAL_OBSERVATION_TEMP_PARENT_INVALID"); + if (process.platform !== "win32") { + const euid = typeof process.geteuid === "function" ? BigInt(process.geteuid()) : -1n; + const mode = posixMode(state); + if (!((state.uid === euid && mode === 0o700) || (state.uid === 0n && mode === 0o1777))) { + fail("GKX_EVAL_OBSERVATION_TEMP_PARENT_INVALID"); + } + } + return directoryIdentity(canonical, state); +} + +export async function createObservationTempCapabilityForTest() { + const parent = await validateTempParent(); + let taskPath = null; + for (let attempt = 0; attempt < 16; attempt += 1) { + const candidate = join(parent.canonical_path, `gkx-retrieval-observation-${randomBytes(16).toString("hex")}`); + try { await mkdir(candidate, { mode: 0o700 }); taskPath = candidate; break; } + catch (error) { if (error?.code !== "EEXIST") throw error; } + } + if (taskPath === null) fail("GKX_EVAL_OBSERVATION_TEMP_CREATE_FAILED"); + if (process.platform !== "win32") await chmod(taskPath, 0o700); + const task = await sealPrivateDirectory(taskPath, "GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_INVALID"); + if (!canonicalPathContains(parent.canonical_path, task.canonical_path) || dirname(task.canonical_path) !== parent.canonical_path) { + fail("GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_INVALID"); + } + const children = {}; + for (const name of ["incremental-state", "clean-rebuild-state"]) { + const child = join(task.canonical_path, name); + await mkdir(child, { mode: 0o700 }); + if (process.platform !== "win32") await chmod(child, 0o700); + children[name] = await sealPrivateDirectory(child, "GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_INVALID"); + await revalidateDirectory(children[name], task); + } + let cleaned = false; + return Object.freeze({ + task_path: task.canonical_path, + incremental_state: children["incremental-state"].canonical_path, + clean_rebuild_state: children["clean-rebuild-state"].canonical_path, + async revalidate() { + if (cleaned) fail("GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_CHANGED"); + await revalidateDirectory(parent); + await revalidateDirectory(task, parent); + await revalidateDirectory(children["incremental-state"], task); + await revalidateDirectory(children["clean-rebuild-state"], task); + }, + async cleanup() { + if (cleaned) fail("GKX_EVAL_OBSERVATION_TEMP_CAPABILITY_CHANGED"); + await revalidateDirectory(parent); + await revalidateDirectory(task, parent); + await revalidateDirectory(children["incremental-state"], task); + await revalidateDirectory(children["clean-rebuild-state"], task); + for (const name of ["incremental-state", "clean-rebuild-state"]) { + const child = children[name]; + await revalidateDirectory(task, parent); + await revalidateDirectory(child, task); + await rm(child.canonical_path, { recursive: true, force: false }); + await revalidateDirectory(task, parent); + } + await revalidateDirectory(task, parent); + await rmdir(task.canonical_path); + cleaned = true; + await revalidateDirectory(parent); + }, + }); +} + +function patchMethod(restores, object, key, replacement) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) return; + Object.defineProperty(object, key, { ...descriptor, value: replacement }); + restores.push(() => Object.defineProperty(object, key, descriptor)); +} + +export function installOfflineGuardsForTest() { + const counters = Object.seal({ + fetch: 0, + http: 0, + https: 0, + http2: 0, + net: 0, + tls: 0, + dns: 0, + dgram: 0, + websocket: 0, + child_process: 0, + }); + const restores = []; + const deny = (family) => function deniedPrimitive() { + counters[family] += 1; + fail(`GKX_EVAL_OBSERVATION_OFFLINE_VIOLATION:${family}`); + }; + const globals = [ + [globalThis, "fetch", "fetch"], + [globalThis, "WebSocket", "websocket"], + ]; + for (const [object, key, family] of globals) patchMethod(restores, object, key, deny(family)); + const http = require("node:http"); + const https = require("node:https"); + const http2 = require("node:http2"); + const net = require("node:net"); + const tls = require("node:tls"); + const dns = require("node:dns"); + const dgram = require("node:dgram"); + const childProcess = require("node:child_process"); + for (const key of ["request", "get"]) patchMethod(restores, http, key, deny("http")); + for (const key of ["request", "get"]) patchMethod(restores, https, key, deny("https")); + for (const key of ["connect", "createServer", "createSecureServer"]) patchMethod(restores, http2, key, deny("http2")); + for (const key of ["connect", "createConnection", "createServer"]) patchMethod(restores, net, key, deny("net")); + for (const key of ["connect", "createServer"]) patchMethod(restores, tls, key, deny("tls")); + for (const key of ["lookup", "resolve", "reverse"]) patchMethod(restores, dns, key, deny("dns")); + for (const key of ["lookup", "resolve", "reverse"]) patchMethod(restores, dns.promises, key, deny("dns")); + patchMethod(restores, dgram, "createSocket", deny("dgram")); + for (const key of ["exec", "execFile", "fork", "spawn", "execSync", "execFileSync", "spawnSync"]) { + patchMethod(restores, childProcess, key, deny("child_process")); + } + let restored = false; + return Object.freeze({ + counters, + restore() { + if (restored) fail("GKX_EVAL_OBSERVATION_OFFLINE_GUARD_INVALID"); + for (const invoke of restores.reverse()) invoke(); + restored = true; + }, + }); +} + +export function exerciseOfflineGuardFamiliesForTest() { + const guard = installOfflineGuardsForTest(); + try { + const http = require("node:http"); + const https = require("node:https"); + const http2 = require("node:http2"); + const net = require("node:net"); + const tls = require("node:tls"); + const dns = require("node:dns"); + const dgram = require("node:dgram"); + const childProcess = require("node:child_process"); + const calls = [ + ["fetch", () => globalThis.fetch("http://127.0.0.1")], + ["http", () => http.request("http://127.0.0.1")], + ["https", () => https.request("https://127.0.0.1")], + ["http2", () => http2.connect("https://127.0.0.1")], + ["net", () => net.connect(1, "127.0.0.1")], + ["tls", () => tls.connect(1, "127.0.0.1")], + ["dns", () => dns.lookup("localhost", () => {})], + ["dgram", () => dgram.createSocket("udp4")], + ["websocket", () => new globalThis.WebSocket("ws://127.0.0.1")], + ["child_process", () => childProcess.spawn(process.execPath, ["--version"])], + ]; + for (const [family, invoke] of calls) { + try { invoke(); fail("GKX_EVAL_OBSERVATION_OFFLINE_NEGATIVE_MISSED"); } + catch (error) { + if (error.message !== `GKX_EVAL_OBSERVATION_OFFLINE_VIOLATION:${family}`) throw error; + } + } + for (const [family, count] of Object.entries(guard.counters)) if (count !== 1) fail(`GKX_EVAL_OBSERVATION_OFFLINE_NEGATIVE_COUNT_INVALID:${family}`); + return { ...guard.counters }; + } finally { guard.restore(); } +} + +export class ConstantEmbeddingProvider { + kind = "local_onnx"; + provider_id = "phase4-observation-local"; + model_id = "phase4-observation-constant-v1"; + dimensions = 4; + timeout_ms = 30_000; + #phase = null; + #records = []; + #offset = 0; + #attempt = null; + #externalCacheReadCount = 0; + + get external_cache_read_count() { return this.#externalCacheReadCount; } + + beginIndexPhase(phase) { + if (this.#phase !== null || this.#attempt !== null) fail("GKX_EVAL_OBSERVATION_PROVIDER_STATE_INVALID"); + this.#phase = phase; + this.#records = []; + this.#offset = 0; + } + + endIndexPhase() { + if (this.#phase === null || this.#attempt !== null) fail("GKX_EVAL_OBSERVATION_PROVIDER_STATE_INVALID"); + const phase = this.#phase; + const records = this.#records; + this.#phase = null; + this.#records = []; + this.#offset = 0; + return { phase, records }; + } + + beginQueryAttempt(attempt) { + if (this.#phase !== null || this.#attempt !== null) fail("GKX_EVAL_OBSERVATION_PROVIDER_STATE_INVALID"); + this.#attempt = attempt; + } + + endQueryAttempt() { + if (this.#attempt === null) fail("GKX_EVAL_OBSERVATION_PROVIDER_STATE_INVALID"); + const attempt = this.#attempt; + this.#attempt = null; + return attempt; + } + + async embed(texts, context = {}) { + if (!Array.isArray(texts) || texts.length < 1 || typeof context.request_id !== "string") fail("GKX_EVAL_OBSERVATION_PROVIDER_REQUEST_INVALID"); + if (this.#phase !== null) { + const inputDigests = texts.map((text) => retrievalSha256(text)); + const expectedId = retrievalSha256(`index\0${this.#offset}\0${inputDigests.join("\0")}`); + if (context.request_id !== expectedId) fail("GKX_EVAL_OBSERVATION_INDEX_REQUEST_ID_INVALID"); + this.#records.push({ + call_ordinal: this.#records.length + 1, + batch_offset: this.#offset, + request_id: context.request_id, + item_count: texts.length, + input_content_digests: inputDigests, + }); + this.#offset += texts.length; + } else if (this.#attempt !== null) { + if (texts.length !== 1 || context.request_id !== retrievalSha256(texts[0])) fail("GKX_EVAL_OBSERVATION_QUERY_REQUEST_ID_INVALID"); + this.#attempt.embedding_call_count += 1; + this.#attempt.embedding_item_count += texts.length; + this.#attempt.embedding_request_id = context.request_id; + } else fail("GKX_EVAL_OBSERVATION_PROVIDER_STATE_INVALID"); + return texts.map(() => Float32Array.of(1, 0, 0, 0)); + } +} + +function indexInput(stateDirectory, corpus) { + return { + state_directory: stateDirectory, + vault_id: PERFORMANCE_VAULT_ID, + source_snapshot_digest: corpus.source_snapshot_digest, + configuration_digest: PERFORMANCE_CONFIGURATION_DIGEST, + policy_digest: PERFORMANCE_POLICY_DIGEST, + chunks: corpus.chunks, + lexical_backend: "sqlite_fts5", + }; +} + +export async function runIndexPhase(provider, phase, stateDirectory, corpus, expected) { + provider.beginIndexPhase(phase); + const start = process.hrtime.bigint(); + const indexed = await indexRetrievalGeneration(indexInput(stateDirectory, corpus), provider); + const durationMicros = elapsedMicros(start); + const observed = provider.endIndexPhase(); + const callCount = observed.records.length; + const itemCount = observed.records.reduce((sum, row) => sum + row.item_count, 0); + const requestSequenceDigest = indexRequestSequenceDigest(phase, observed.records); + const expectedManifest = expectedPerformanceManifests()[phase === "initial_index" ? "initial" : "updated"]; + if (stableJson(indexed.generation.manifest) !== stableJson(expectedManifest)) { + fail("GKX_EVAL_OBSERVATION_INDEX_RECEIPT_MISMATCH"); + } + if (callCount !== expected.provider_call_count || itemCount !== expected.provider_item_count || + requestSequenceDigest !== expected.index_request_sequence_digest || + indexed.generation.manifest.projection_id !== expected.expected_projection_id || + indexed.generation.manifest.projection_digest !== expected.expected_projection_digest || + indexed.vector_stage.kind !== "local_onnx" || indexed.vector_stage.state !== "active") { + fail("GKX_EVAL_OBSERVATION_INDEX_RECEIPT_MISMATCH"); + } + return Object.freeze({ + phase, + duration_micros: durationMicros, + provider_call_count: callCount, + provider_item_count: itemCount, + index_request_sequence_digest: requestSequenceDigest, + projection_id: indexed.generation.manifest.projection_id, + projection_digest: indexed.generation.manifest.projection_digest, + manifest: indexed.generation.manifest, + database_path: indexed.generation.database_path, + }); +} + +function queryRequestSequenceDigest(phase, requestIds) { + return retrievalCanonicalDigest({ + contract_version: QUERY_REQUEST_SEQUENCE_VERSION, + phase, + request_ids: requestIds, + }); +} + +export function readReuseRows(databasePath) { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(databasePath, { readOnly: true }); + try { + return db.prepare('SELECT c.source_id, c.structural_position, c.part_ordinal, c.chunk_id, c.content_digest, c.source_digest, v.vector_json FROM chunks c JOIN chunk_vectors v USING (chunk_id) ORDER BY c.chunk_id').all(); + } finally { db.close(); } +} + +export function verifyReuseRows(before, after) { + const coordinate = row => stableJson([row.source_id, row.structural_position, row.part_ordinal]); + const previous = new Map(before.map(row => [coordinate(row), row])); + if (previous.size !== before.length || new Set(after.map(coordinate)).size !== after.length) fail('OBS_UPDATE_REUSE_INVALID'); + let unchanged = 0, changed = 0, changedSourceRecords = 0; + for (const row of after) { + const prior = previous.get(coordinate(row)); + if (!prior) fail('OBS_UPDATE_REUSE_INVALID'); + if (prior.source_digest !== row.source_digest) changedSourceRecords++; + if (prior.content_digest === row.content_digest) { + if (prior.chunk_id !== row.chunk_id || prior.vector_json !== row.vector_json) fail('OBS_UPDATE_REUSE_INVALID'); + unchanged++; + } else changed++; + } + if (before.length !== 10000 || after.length !== 10000 || unchanged !== 9999 || changed !== 1 || changedSourceRecords !== 10) fail('OBS_UPDATE_REUSE_INVALID'); + return { unchanged_vectors_verified: unchanged, changed_content_count: changed, changed_source_record_count: changedSourceRecords }; +} + +function stageExpectation() { + return performanceSamplePlan().execution.incremental_query_work.result_stage_expectation; +} + +function normalizedPlatform() { + if (platform() === "win32") return "windows"; + if (platform() === "darwin") return "darwin"; + if (platform() === "linux") return "linux"; + fail("GKX_EVAL_OBSERVATION_PLATFORM_INVALID"); +} + +function createQueryObserver() { + let attempt = null; + return Object.freeze({ + begin(value) { if (attempt !== null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); attempt = value; }, + end() { if (attempt === null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); const value = attempt; attempt = null; return value; }, + sql_stage(kind) { + if (attempt === null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); + if (kind === "lexical") attempt.fts_query_stage_count += 1; + else if (kind === "vector") attempt.vector_query_stage_count += 1; + else fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); + }, + ranking() { if (attempt === null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); attempt.ranking_call_count += 1; }, + confidence() { if (attempt === null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); attempt.confidence_call_count += 1; }, + citation() { if (attempt === null) fail("GKX_EVAL_OBSERVATION_QUERY_OBSERVER_INVALID"); attempt.citation_verification_count += 1; }, + }); +} + +export function observedQueryCacheHitCountForTest(embeddingCallCount) { + if (!Number.isSafeInteger(embeddingCallCount) || embeddingCallCount < 0 || embeddingCallCount > 1) { + fail("GKX_EVAL_OBSERVATION_QUERY_CACHE_LEDGER_INVALID"); + } + return embeddingCallCount === 0 ? 1 : 0; +} + +async function runQueryPhase(phase, databasePath, corpus, provider, repeatCount, measuredFromRound) { + const queryCycle = performanceQueryCycle(); + const sources = new Map(corpus.sources.map((source) => [source.source_path, source.bytes])); + const observer = createQueryObserver(); + const coordinator = coordinatorFromRetrievalEvaluationDatabase(databasePath, { + discoverability_policy: () => "allow", + vector_provider: provider, + source_reader: async (path) => { + const bytes = sources.get(path); + if (!bytes) fail("GKX_EVAL_OBSERVATION_SOURCE_READ_INVALID"); + return new Uint8Array(bytes); + }, + stale: false, + }, observer, true); + const attempts = []; + try { + for (let round = 1; round <= repeatCount; round += 1) { + for (let queryIndex = 0; queryIndex < queryCycle.queries.length; queryIndex += 1) { + const query = queryCycle.queries[queryIndex]; + const mutable = { + embedding_call_count: 0, + embedding_item_count: 0, + embedding_request_id: null, + fts_query_stage_count: 0, + vector_query_stage_count: 0, + ranking_call_count: 0, + confidence_call_count: 0, + citation_verification_count: 0, + query_cache_hit_count: null, + }; + provider.beginQueryAttempt(mutable); + observer.begin(mutable); + const measured = round >= measuredFromRound; + const start = measured ? process.hrtime.bigint() : 0n; + const result = await coordinator.search({ query: query.query_text, ...queryCycle.request }); + const latencyMicros = measured ? elapsedMicros(start) : null; + mutable.query_cache_hit_count = observedQueryCacheHitCountForTest(mutable.embedding_call_count); + const providerReceipt = provider.endQueryAttempt(); + const observerReceipt = observer.end(); + if (providerReceipt !== observerReceipt || mutable.embedding_call_count !== 1 || mutable.embedding_item_count !== 1 || + mutable.embedding_request_id !== query.request_id || mutable.fts_query_stage_count !== 1 || + mutable.vector_query_stage_count !== 1 || mutable.ranking_call_count !== 1 || mutable.confidence_call_count !== 1 || + mutable.query_cache_hit_count !== 0 || + result.contract_version !== "gkos-retrieval/1.0.0-draft.1") { + fail("GKX_EVAL_OBSERVATION_QUERY_WORK_MISMATCH"); + } + assertExact({ result_contract_version: result.contract_version, ...result.stages }, stageExpectation(), "GKX_EVAL_OBSERVATION_RESULT_STAGE_MISMATCH"); + attempts.push({ + attempt_ordinal: attempts.length + 1, + round_ordinal: round, + query_ordinal: queryIndex + 1, + query_id: query.query_id, + query_text_digest: retrievalSha256(query.query_text), + embedding_request_id: mutable.embedding_request_id, + embedding_item_count: mutable.embedding_item_count, + fts_query_stage_count: mutable.fts_query_stage_count, + reranker_call_count: 0, + reranker_item_count: 0, + query_cache_hit_count: mutable.query_cache_hit_count, + result_digest: retrievalCanonicalDigest(result), + result_stage_digest: retrievalCanonicalDigest({ contract_version: result.contract_version, stages: result.stages }), + measured_latency_micros: latencyMicros, + }); + } + } + } finally { coordinator.close(); } + const expectedWork = phase === "incremental_observation" + ? performanceSamplePlan().execution.incremental_query_work + : performanceSamplePlan().execution.clean_rebuild_query_work; + const requestIds = attempts.map((row) => row.embedding_request_id); + const requestSequenceDigest = queryRequestSequenceDigest(phase, requestIds); + if (attempts.length !== expectedWork.attempt_count || requestSequenceDigest !== expectedWork.request_id_sequence_digest || + attempts.reduce((sum, row) => sum + (row.embedding_request_id === null ? 0 : 1), 0) !== expectedWork.embedding_call_count || + attempts.reduce((sum, row) => sum + row.embedding_item_count, 0) !== expectedWork.embedding_item_count || + attempts.reduce((sum, row) => sum + row.fts_query_stage_count, 0) !== expectedWork.fts_query_stage_count || + attempts.reduce((sum, row) => sum + row.query_cache_hit_count, 0) !== expectedWork.query_cache_hit_count) { + fail("GKX_EVAL_OBSERVATION_QUERY_LEDGER_MISMATCH"); + } + return Object.freeze({ + phase, + expected_query_work_digest: expectedWork.query_work_digest, + attempt_count: attempts.length, + embedding_call_count: attempts.reduce((sum, row) => sum + (row.embedding_request_id === null ? 0 : 1), 0), + embedding_item_count: attempts.reduce((sum, row) => sum + row.embedding_item_count, 0), + embedding_request_id_sequence_digest: requestSequenceDigest, + fts_query_stage_count: attempts.reduce((sum, row) => sum + row.fts_query_stage_count, 0), + reranker_call_count: 0, + reranker_item_count: 0, + query_cache_hit_count: attempts.reduce((sum, row) => sum + row.query_cache_hit_count, 0), + result_stage_assertion_count: attempts.length, + result_stage_mismatch_count: 0, + attempt_set_digest: queryAttemptSetDigest(phase, attempts), + attempts, + }); +} + +function nearestRank(samples, index) { + const sorted = [...samples].sort((left, right) => left - right); + return sorted[index]; +} + +function resultRows(ledger, firstAttemptOrdinal) { + const cycle = performanceQueryCycle(); + return ledger.attempts.slice(firstAttemptOrdinal - 1, firstAttemptOrdinal - 1 + cycle.queries.length).map((attempt, index) => ({ + query_id: cycle.queries[index].query_id, + query_text: cycle.queries[index].query_text, + result_digest: attempt.result_digest, + })); +} + +export function measuredRoundsIdenticalForTest(attempts) { + const byQuery = new Map(); + for (const attempt of attempts) { + if (!byQuery.has(attempt.query_id)) byQuery.set(attempt.query_id, []); + byQuery.get(attempt.query_id).push(attempt); + } + if (byQuery.size !== 10) return false; + for (const rows of byQuery.values()) { + rows.sort((left, right) => left.round_ordinal - right.round_ordinal); + if (rows.length !== 6 || rows.some((row, index) => row.round_ordinal !== index + 1) || + rows.some((row) => row.result_digest !== rows[0].result_digest)) return false; + } + return true; +} + +function throwObservation(code, error) { + if (OBSERVATION_FAILURE_CODES.has(error?.message)) throw error; + if (typeof error?.message === "string" && error.message.startsWith("GKX_EVAL_OBSERVATION_OFFLINE_VIOLATION:")) { + fail("OBS_NETWORK_ATTEMPTED"); + } + fail(code); +} + +async function observationStage(code, operation) { + try { return await operation(); } + catch (error) { throwObservation(code, error); } +} + +async function runObservation(repoRoot, artifactRoot, source) { + if (ENGINE_VERSION !== "2.2.0" || + git(repoRoot, ["rev-parse", "--is-shallow-repository"]) !== "false" || + !source.worktree_clean || !source.runner_committed_at_checkout || + source.checkout_commit !== source.source_head_commit) fail("OBS_SOURCE_PROVENANCE_INVALID"); + if (normalizedPlatform() !== "linux" || arch() !== "x64") fail("OBS_REPORT_INVALID"); + try { await verifyFrozenQualificationInputsForTest(repoRoot); } + catch (error) { throwObservation("OBS_PACK_IMMUTABILITY_INVALID", error); } + const offline = installOfflineGuardsForTest(); + let temporary = null; + let offlineRestored = false; + try { + let plan; + try { plan = performanceSamplePlan(); } + catch (error) { throwObservation("OBS_FIXTURE_INVALID", error); } + await artifactRoot.write(OBSERVATION_PLAN_FILE, plan); + const capability = detectSqliteLexicalCapability(); + if (!capability.fts5_available) fail("OBS_FTS5_UNAVAILABLE"); + temporary = await createObservationTempCapabilityForTest(); + let initial; + let updated; + try { + initial = buildPerformanceCorpus(false); + updated = buildPerformanceCorpus(true); + } catch (error) { throwObservation("OBS_FIXTURE_INVALID", error); } + await temporary.revalidate(); + const coordinates = expectedPerformanceCoordinates(); + const indexProvider = new ConstantEmbeddingProvider(); + const initialIndex = await observationStage("OBS_INDEX_FAILED", () => + runIndexPhase(indexProvider, "initial_index", temporary.incremental_state, initial, coordinates.index.initial)); + if (initialIndex.provider_call_count !== 313 || initialIndex.provider_item_count !== 10_000) { + fail("OBS_INDEX_PROVIDER_LEDGER_INVALID"); + } + const initialReuseRows = readReuseRows(initialIndex.database_path); + const updateIndex = await observationStage("OBS_UPDATE_FAILED", () => + runIndexPhase(indexProvider, "incremental_update", temporary.incremental_state, updated, coordinates.index.incremental_update)); + if (updateIndex.provider_call_count !== 1 || updateIndex.provider_item_count !== 1) fail("OBS_UPDATE_REUSE_INVALID"); + const reuse = verifyReuseRows(initialReuseRows, readReuseRows(updateIndex.database_path)); + const queryProvider = new ConstantEmbeddingProvider(); + const incrementalQueries = await observationStage("OBS_QUERY_FAILED", () => + runQueryPhase("incremental_observation", updateIndex.database_path, updated, queryProvider, 6, 2)); + const measuredRoundsIdentical = measuredRoundsIdenticalForTest(incrementalQueries.attempts); + if (!measuredRoundsIdentical) fail("OBS_QUERY_SAMPLE_INVALID"); + const rebuildIndex = await observationStage("OBS_REBUILD_FAILED", () => + runIndexPhase(indexProvider, "clean_rebuild", temporary.clean_rebuild_state, updated, coordinates.index.clean_rebuild)); + const cleanQueries = await observationStage("OBS_QUERY_FAILED", () => + runQueryPhase("clean_rebuild_comparison", rebuildIndex.database_path, updated, queryProvider, 1, 2)); + const manifestEqual = stableJson(updateIndex.manifest) === stableJson(rebuildIndex.manifest); + if (!manifestEqual) fail("OBS_CONVERGENCE_INVALID"); + const incrementalResults = resultRows(incrementalQueries, 51); + const cleanResults = resultRows(cleanQueries, 1); + if (stableJson(incrementalResults) !== stableJson(cleanResults)) fail("OBS_CONVERGENCE_INVALID"); + const incrementalResultSetDigest = resultSetDigest(incrementalResults); + const cleanResultSetDigest = resultSetDigest(cleanResults); + if (incrementalResultSetDigest !== cleanResultSetDigest) fail("OBS_CONVERGENCE_INVALID"); + const latencies = incrementalQueries.attempts.map((row) => row.measured_latency_micros).filter((value) => value !== null); + if (latencies.length !== 50 || latencies.some((value) => !Number.isSafeInteger(value) || value < 0)) fail("OBS_QUERY_SAMPLE_INVALID"); + const p50 = nearestRank(latencies, 24); + const p95 = nearestRank(latencies, 47); + const p99 = nearestRank(latencies, 49); + if (!(p95 < 500_000)) fail("OBS_QUERY_P95_EXCEEDED"); + const networkAttemptCount = Object.values(offline.counters).reduce((sum, count) => sum + count, 0); + if (networkAttemptCount !== 0) fail("OBS_NETWORK_ATTEMPTED"); + const externalCacheReadCount = indexProvider.external_cache_read_count + queryProvider.external_cache_read_count; + if (externalCacheReadCount !== 0 || incrementalQueries.query_cache_hit_count !== 0 || cleanQueries.query_cache_hit_count !== 0) { + fail("OBS_QUERY_SAMPLE_INVALID"); + } + await observationStage("OBS_REPORT_INVALID", () => temporary.cleanup()); + temporary = null; + offline.restore(); + offlineRestored = true; + const environment = { + runtime: "node", + runtime_version: process.versions.node, + os: "linux", + arch: "x64", + sqlite_version: capability.sqlite_version, + lexical_backend: "sqlite_fts5", + fts5_available: true, + runner_class: process.env.GITHUB_ACTIONS === "true" ? "github_hosted" : "local", + }; + const reportMaterial = { + contract_version: "gkos-retrieval-evaluation-observation/1.0.0-draft.1", + evaluation_digest: PERFORMANCE_EVALUATION_DIGEST, + fixed_sample_plan_digest: PERFORMANCE_SAMPLE_PLAN_DIGEST, + environment, + warmup_count: 10, + sample_count: 50, + query_latency_micros: { p50, p95, p99 }, + index_time_micros: initialIndex.duration_micros, + update_time_micros: updateIndex.duration_micros, + chunks_reprocessed: 1, + chunks_reused: 9_999, + }; + let observationReport; + try { + observationReport = sealRetrievalEvaluationObservationReport({ + ...reportMaterial, + observation_digest: retrievalCanonicalDigest(reportMaterial), + }); + } catch (error) { throwObservation("OBS_REPORT_INVALID", error); } + const reportBytes = Buffer.from(prettyCanonical(observationReport), "utf8"); + const incrementalQueryWork = { ...incrementalQueries }; + const cleanQueryWork = { ...cleanQueries }; + delete incrementalQueryWork.attempts; + delete cleanQueryWork.attempts; + const receipt = buildObservationReceiptForTest({ + failure_codes: [], + publication_eligible: publicationEligibleForTest(source), + source, + fixture: { + fixture_digest: PERFORMANCE_FIXTURE_DIGEST, + source_count: 1_000, + sections_per_source: 10, + chunk_count: 10_000, + mutation: { + global_chunk_ordinal: 5_555, + source_ordinal: 555, + section_ordinal: 5, + from: "revisionalpha", + to: "revisionomega", + }, + changed_content_digest_count: 1, + changed_source_chunk_record_count: 10, + initial_source_snapshot_digest: initial.source_snapshot_digest, + updated_source_snapshot_digest: updated.source_snapshot_digest, + initial_chunk_set_digest: initial.chunk_set_digest, + updated_chunk_set_digest: updated.chunk_set_digest, + }, + environment, + indexing: { + index_time_micros: initialIndex.duration_micros, + update_time_micros: updateIndex.duration_micros, + network_attempt_count: networkAttemptCount, + external_cache_read_count: externalCacheReadCount, + initial: { + index_request_sequence_digest: initialIndex.index_request_sequence_digest, + provider_call_count: initialIndex.provider_call_count, + provider_item_count: initialIndex.provider_item_count, + projection_digest: initialIndex.projection_digest, + }, + incremental_update: { + index_request_sequence_digest: updateIndex.index_request_sequence_digest, + provider_call_count: updateIndex.provider_call_count, + provider_item_count: updateIndex.provider_item_count, + projection_digest: updateIndex.projection_digest, + chunks_reprocessed: 1, + chunks_reused: 9_999, + reuse, + }, + clean_rebuild: { + index_request_sequence_digest: rebuildIndex.index_request_sequence_digest, + provider_call_count: rebuildIndex.provider_call_count, + provider_item_count: rebuildIndex.provider_item_count, + projection_digest: rebuildIndex.projection_digest, + }, + }, + query_latency: { + warmup_count: 10, + sample_count: 50, + samples_micros: latencies, + sample_vector_digest: sampleVectorDigest(latencies), + p50_micros: p50, + p95_micros: p95, + p99_micros: p99, + p95_strict_upper_bound_micros: 500_000, + incremental_query_work: incrementalQueryWork, + clean_rebuild_query_work: cleanQueryWork, + }, + convergence: { + incremental_projection_digest: updateIndex.projection_digest, + clean_rebuild_projection_digest: rebuildIndex.projection_digest, + manifest_equal: manifestEqual, + incremental_result_set_digest: incrementalResultSetDigest, + clean_rebuild_result_set_digest: cleanResultSetDigest, + result_set_equal: true, + measured_rounds_identical: measuredRoundsIdentical, + }, + observation_report: { + observation_digest: observationReport.observation_digest, + byte_size: reportBytes.length, + raw_sha256: sha256Bytes(reportBytes), + }, + }); + await artifactRoot.write(OBSERVATION_RECEIPT_FILE, receipt); + await artifactRoot.write(OBSERVATION_REPORT_FILE, observationReport); + return receipt; + } finally { + if (temporary !== null) { + try { await temporary.cleanup(); } catch { /* fail-retain on capability ambiguity */ } + } + if (!offlineRestored) { + try { offline.restore(); } catch { /* retain the original failure */ } + } + } +} + +export function qualificationSamplePlanForTest() { + const preimage = { ...CLI_SAMPLE_PLAN }; + delete preimage.sample_plan_digest; + if (retrievalCanonicalDigest(preimage) !== CLI_SAMPLE_PLAN_DIGEST) fail("GKX_EVAL_QUALIFICATION_SAMPLE_PLAN_INVALID"); + return structuredClone(CLI_SAMPLE_PLAN); +} + +export function parseTapForTest(output, wallDurationMicros = 0) { + if (typeof output !== "string" || !Number.isSafeInteger(wallDurationMicros) || wallDurationMicros < 0) { + fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + } + const headings = []; + const outcomes = []; + const durations = new Map(); + const summaryValues = new Map(); + let reporterDurationMicros = null; + let activeName = null; + for (const line of output.split(/\r?\n/u)) { + const heading = /^# Subtest: (.+)$/u.exec(line); + if (heading) { activeName = heading[1]; headings.push(activeName); continue; } + const duration = /^\s+duration_ms: ((?:0|[1-9][0-9]*)(?:\.[0-9]+)?)$/u.exec(line); + if (duration && activeName !== null) { + if (durations.has(activeName)) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + durations.set(activeName, decimalMillisToCeilMicrosForTest(duration[1])); + activeName = null; + continue; + } + const outcome = /^(ok|not ok) ([1-9][0-9]*) - (.+?)(?: #.*)?$/u.exec(line); + if (outcome) { + const ordinal = Number(outcome[2]); + if (!Number.isSafeInteger(ordinal)) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + outcomes.push({ ok: outcome[1] === "ok", ordinal, name: outcome[3] }); + } + const summary = /^# (tests|pass|fail|cancelled|skipped|todo) ([0-9]+)$/u.exec(line); + if (summary) { + if (summaryValues.has(summary[1])) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + const count = Number(summary[2]); + if (!Number.isSafeInteger(count)) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + summaryValues.set(summary[1], count); + } + const reporter = /^# duration_ms ((?:0|[1-9][0-9]*)(?:\.[0-9]+)?)$/u.exec(line); + if (reporter) { + if (reporterDurationMicros !== null) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + reporterDurationMicros = decimalMillisToCeilMicrosForTest(reporter[1]); + } + } + const uniqueNames = new Set(headings); + if (headings.length === 0 || uniqueNames.size !== headings.length || outcomes.length !== headings.length || + reporterDurationMicros === null || [...["tests", "pass", "fail", "cancelled", "skipped", "todo"]] + .some((key) => !summaryValues.has(key))) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + for (let index = 0; index < outcomes.length; index += 1) { + if (outcomes[index].ordinal !== index + 1 || outcomes[index].name !== headings[index] || !durations.has(headings[index])) { + fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + } + } + const summary = Object.freeze({ + tests: summaryValues.get("tests"), + pass: summaryValues.get("pass"), + fail: summaryValues.get("fail"), + cancelled: summaryValues.get("cancelled"), + skipped: summaryValues.get("skipped"), + todo: summaryValues.get("todo"), + reporter_duration_micros: reporterDurationMicros, + wall_duration_micros: wallDurationMicros, + }); + if (summary.tests !== headings.length) fail("GKX_EVAL_QUALIFICATION_TAP_INVALID"); + return Object.freeze({ summary, headings, outcomes, durations }); +} + +function runNodeTest(repoRoot, files) { + return new Promise((resolvePromise, rejectPromise) => { + const started = process.hrtime.bigint(); + const child = spawn(process.execPath, ["--test", "--test-concurrency=1", "--test-reporter=tap", ...files], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const stdout = []; + let stdoutBytes = 0; + let stdoutOverflow = false; + let watchdogTriggered = false; + child.stdout.on("data", (chunk) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= 8 * 1024 * 1024) stdout.push(chunk); + else stdoutOverflow = true; + process.stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => process.stderr.write(chunk)); + const timer = setTimeout(() => { watchdogTriggered = true; child.kill("SIGTERM"); }, 660_000); + child.once("error", (error) => { clearTimeout(timer); rejectPromise(error); }); + child.once("exit", (code, signal) => { + clearTimeout(timer); + const delta = process.hrtime.bigint() - started; + const wallDurationMicros = Number((delta + 999n) / 1_000n); + resolvePromise({ + wall_duration_micros: wallDurationMicros, + exit_code: code, + signal, + stdout: Buffer.concat(stdout).toString("utf8"), + stdout_overflow: stdoutOverflow, + watchdog_triggered: watchdogTriggered, + }); + }); + }); +} + +export function exactTestTotalsForTest(summary, expected) { + return summary !== null && summary.tests === expected && summary.pass === expected && summary.fail === 0 && + summary.cancelled === 0 && summary.skipped === 0 && summary.todo === 0; +} + +export function buildQualificationReceiptForTest(value) { + const failureCodes = sortedFailureCodes(value.failure_codes, CLI_FAILURE_CODES); + if (failureCodes.length === 0) { + const commonPresent = value.source !== null && value.environment !== null && value.immutable_inputs !== null; + const cliPresent = value.cli_test_summary !== null && value.temporal_noninterference !== null && + value.tune_qualification !== null && value.windows_security === null; + const windowsPresent = value.cli_test_summary === null && value.temporal_noninterference === null && + value.tune_qualification === null && value.windows_security !== null; + if (!commonPresent || cliPresent === windowsPresent) fail("GKX_EVAL_QUALIFICATION_RECEIPT_NULLABILITY_INVALID"); + } + const material = { + contract_version: CLI_RECEIPT_VERSION, + status: failureCodes.length === 0 ? "pass" : "fail", + failure_codes: failureCodes, + source: value.source, + environment: value.environment, + immutable_inputs: value.immutable_inputs, + sample_plan: qualificationSamplePlanForTest(), + cli_test_summary: value.cli_test_summary, + temporal_noninterference: value.temporal_noninterference, + tune_qualification: value.tune_qualification, + windows_security: value.windows_security, + }; + return Object.freeze({ ...material, qualification_digest: retrievalCanonicalDigest(material) }); +} + +export function temporalObservationForTest(parsed) { + const name = CLI_SAMPLE_PLAN.eval_test_name; + const matches = parsed.headings.filter((value) => value === name); + const outcome = parsed.outcomes.find((value) => value.name === name); + const duration = parsed.durations.get(name); + if (matches.length !== 1 || !outcome?.ok || duration === undefined) return null; + return Object.freeze({ + test_name: name, + duration_micros: duration, + reviewed_query_count: 24, + absent_pair_count: 1, + pair_id: "temporal-future-present-absent", + query_id: "temporal-future-exclusion", + public_view_digest: "sha256:6912095efe8518662a93267c240b65d3eceb7e864229adf959ff87e8bdc9360e", + query_metrics_digest: "sha256:0afc69cc15152680205788b6354c8fd34f15812af1ecca030deecd90fc69c510", + query_counter_digest: "sha256:8d7f3b5575bc79396d4d13b3cd3ed161cfef2621adb430071181160e957f5759", + pair_digest: "sha256:3939b4d906b0b358cb41cade641b9407e0e014b2288cfe275e79853e120e732e", + comparison_digest: "sha256:44333a25dc9c40a10e09316a3da2183fd2cb28b19736f269d410727fe1f5f3ae", + }); +} + +export function tuneObservationForTest(parsed) { + const name = CLI_SAMPLE_PLAN.tune_test_name; + const matches = parsed.headings.filter((value) => value === name); + const outcome = parsed.outcomes.find((value) => value.name === name); + const duration = parsed.durations.get(name); + if (matches.length !== 1 || !outcome?.ok || duration === undefined) return null; + return Object.freeze({ + test_name: name, + duration_micros: duration, + evaluated_candidate_count: 900, + query_evaluation_count: 21_600, + candidate_config_digest: "sha256:6d55a381e2fb74b87e0cfabe010ff168f155d7b12258c062fcc08372f1934050", + candidate_evaluation_digest: "sha256:0af5053fccb84ae0a9eb3b785a3760e20438300dd49d512c6ab480bfe299e433", + tune_selection_digest: "sha256:7dc97fbdfe7c0d489622f17f1b1e0ed7b629c5d562f05a5c9b35ed6dd7a2d0e4", + }); +} + +async function runCliQualification(repoRoot, artifactRoot, windowsSecurity) { + const source = await qualificationSourceReceipt(repoRoot); + const codes = []; + let immutableInputs = null; + let environment = null; + try { immutableInputs = await verifyFrozenQualificationInputsForTest(repoRoot); } + catch (error) { + codes.push(error?.message === "GKX_EVAL_QUALIFICATION_CLI_FIXTURE_INVALID" ? "QUAL_CLI_FIXTURE_INVALID" : "QUAL_PACK_INVALID"); + } + try { environment = qualificationEnvironment(); } + catch { codes.push("QUAL_ENVIRONMENT_INVALID"); } + if (windowsSecurity && (process.env.GKOS_REQUIRE_ALIAS_FIXTURE !== "1" || process.env.GKOS_REQUIRE_SHORT_PATH_FIXTURE !== "1")) { + codes.push("QUAL_ENVIRONMENT_INVALID"); + } + + const files = windowsSecurity ? CLI_SAMPLE_PLAN.windows_security_test_files : CLI_SAMPLE_PLAN.cli_test_files; + let child = null; + let parsed = null; + if (codes.length === 0) { + try { child = await runNodeTest(repoRoot, files); } + catch { codes.push(windowsSecurity ? "QUAL_WINDOWS_PROCESS_FAILED" : "QUAL_CLI_PROCESS_FAILED"); } + if (child !== null) { + if (child.exit_code !== 0 || child.signal !== null || child.stdout_overflow || child.watchdog_triggered) { + codes.push(windowsSecurity ? "QUAL_WINDOWS_PROCESS_FAILED" : "QUAL_CLI_PROCESS_FAILED"); + } + try { parsed = parseTapForTest(child.stdout, child.wall_duration_micros); } + catch { codes.push(windowsSecurity ? "QUAL_WINDOWS_TAP_INVALID" : "QUAL_CLI_TAP_INVALID"); } + } + } + + let cliSummary = null; + let temporal = null; + let tune = null; + let windows = null; + if (parsed !== null) { + if (windowsSecurity) { + if (!exactTestTotalsForTest(parsed.summary, CLI_SAMPLE_PLAN.windows_security_expected_test_count)) { + codes.push("QUAL_WINDOWS_TEST_TOTAL_INVALID"); + } + if (parsed.summary.wall_duration_micros > CLI_SAMPLE_PLAN.thresholds_micros.windows_security_wall) { + codes.push("QUAL_WINDOWS_WALL_BUDGET_EXCEEDED"); + } + windows = { + test_files: [...CLI_SAMPLE_PLAN.windows_security_test_files], + expected_test_count: CLI_SAMPLE_PLAN.windows_security_expected_test_count, + test_summary: parsed.summary, + alias_fixture_required: true, + short_path_fixture_required: true, + }; + } else { + cliSummary = parsed.summary; + if (!exactTestTotalsForTest(parsed.summary, CLI_SAMPLE_PLAN.cli_expected_test_count)) codes.push("QUAL_CLI_TEST_TOTAL_INVALID"); + temporal = temporalObservationForTest(parsed); + tune = tuneObservationForTest(parsed); + if (temporal === null) codes.push("QUAL_TEMPORAL_TEST_MISSING"); + if (tune === null) codes.push("QUAL_TUNE_TEST_MISSING"); + if (temporal !== null && temporal.duration_micros > CLI_SAMPLE_PLAN.thresholds_micros.eval_test) codes.push("QUAL_EVAL_BUDGET_EXCEEDED"); + if (tune !== null && tune.duration_micros > CLI_SAMPLE_PLAN.thresholds_micros.tune_test) codes.push("QUAL_TUNE_BUDGET_EXCEEDED"); + if (parsed.summary.wall_duration_micros > CLI_SAMPLE_PLAN.thresholds_micros.cli_wall) codes.push("QUAL_CLI_WALL_BUDGET_EXCEEDED"); + } + } + const requiredPresent = windowsSecurity + ? windows !== null + : cliSummary !== null && temporal !== null && tune !== null; + if (!requiredPresent && codes.length === 0) codes.push(windowsSecurity ? "QUAL_WINDOWS_TAP_INVALID" : "QUAL_CLI_TAP_INVALID"); + const receipt = buildQualificationReceiptForTest({ + failure_codes: codes, + source, + environment, + immutable_inputs: immutableInputs, + cli_test_summary: cliSummary, + temporal_noninterference: temporal, + tune_qualification: tune, + windows_security: windows, + }); + await artifactRoot.write(CLI_RECEIPT_FILE, receipt); + return receipt; +} + +async function writeObservationFailureReceipt(artifactRoot, source, code) { + const receipt = buildObservationReceiptForTest({ + failure_codes: [code], + publication_eligible: false, + source, + fixture: null, + environment: null, + indexing: null, + query_latency: null, + convergence: null, + observation_report: null, + }); + try { await artifactRoot.write(OBSERVATION_RECEIPT_FILE, receipt); } catch { /* original failure governs */ } +} + +export async function main(argv = process.argv.slice(2)) { + const parsed = parseArgs(argv); + const repoRoot = resolve(process.cwd()); + if (parsed.mode === "plan") { + process.stdout.write(prettyCanonical(performanceSamplePlan())); + return; + } + if (parsed.mode === "offline-self-test") { + process.stdout.write(`${stableJson(exerciseOfflineGuardFamiliesForTest())}\n`); + return; + } + if (parsed.mode === "immutability") { + await verifyFrozenQualificationInputsForTest(repoRoot); + return; + } + const artifactRoot = await observationArtifactRoot(parsed.artifact_root); + if (parsed.mode === "cli" || parsed.mode === "windows-security") { + const receipt = await runCliQualification(repoRoot, artifactRoot, parsed.mode === "windows-security"); + if (receipt.status !== "pass") process.exitCode = 1; + return; + } + let source = null; + try { + source = await observationSourceReceipt(repoRoot); + await runObservation(repoRoot, artifactRoot, source); + } + catch (error) { + const code = typeof error?.message === "string" && OBSERVATION_FAILURE_CODES.has(error.message) + ? error.message + : "OBS_REPORT_INVALID"; + await writeObservationFailureReceipt(artifactRoot, source, code); + process.stderr.write(`phase4 retrieval observation: ${code}; inspect observation-receipt.json\n`); + process.exitCode = 1; + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath !== null && invokedPath === resolve(fileURLToPath(import.meta.url)) && process.argv[2] === "--mode") { + main().catch((error) => { + process.stderr.write("phase4 retrieval qualification: OBS_REPORT_INVALID\n"); + process.exitCode = 2; + }); +} diff --git a/test/retrieval-observation-2.2.test.mjs b/test/retrieval-observation-2.2.test.mjs new file mode 100644 index 0000000..4390525 --- /dev/null +++ b/test/retrieval-observation-2.2.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile, mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import test from 'node:test'; +import esbuild from 'esbuild'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const temp = await mkdtemp(join(tmpdir(), 'gkos-observation22-test-')); +test.after(() => rm(temp, { recursive: true, force: true })); +await esbuild.build({ entryPoints: [join(root, 'scripts/generate-retrieval-observation-fixture-2.2.mjs')], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, 'fixture.mjs'), logLevel: 'silent' }); +const fixture = await import(pathToFileURL(join(temp, 'fixture.mjs'))); + +test('2.2 expected projection preimages reproduce fixed pins and retain workload', () => { + const plan = fixture.performanceSamplePlan(); + const manifests = fixture.expectedPerformanceManifests(); + assert.equal(plan.indexing.engine_version, '2.2.0'); + assert.equal(plan.indexing.projection_schema_version, 2); + assert.equal(plan.fixture.chunk_count, 10000); + assert.equal(plan.indexing.initial.provider_call_count, 313); + assert.equal(plan.indexing.initial.provider_item_count, 10000); + assert.equal(plan.indexing.incremental_update.provider_item_count, 1); + assert.equal(plan.indexing.incremental_update.chunks_reused, 9999); + assert.equal(plan.percentile.p95_strict_upper_bound_micros, 500000); + assert.equal(plan.execution.sample_count, 50); + assert.equal(manifests.initial.projection_digest, fixture.PINS.initial_projection_digest); + assert.equal(manifests.updated.projection_digest, fixture.PINS.updated_projection_digest); + assert.equal(plan.indexing.clean_rebuild.expected_projection_digest, manifests.updated.projection_digest); + const original = execFileSync('git', ['show', '650eab4a6752227cae336d7556a57826c22a0d5a:scripts/generate-retrieval-observation-fixture.mjs'], { cwd: root }); + return readFile(join(root, 'scripts/generate-retrieval-observation-fixture.mjs')).then(bytes => assert.deepEqual(bytes, original)); +}); + +test('2.2 oracle rejects changed chunks instead of adopting an arbitrary digest', async () => { + // Mutate a bundled in-memory fixture copy, leaving all source files intact. + const bundle = await readFile(join(temp, 'fixture.mjs'), 'utf8'); + const mutated = bundle.replace('07115aadce8907fbb5829bc7ec6927c458f4a7df0facd6d9f85a711c84c97cec', '0'.repeat(64)); + assert.notEqual(mutated, bundle); + const module = await import('data:text/javascript;base64,' + Buffer.from(mutated).toString('base64')); + assert.throws(() => module.performanceSamplePlan(), /OBS_FIXTURE_INVALID/); +}); + +test('separate full-history historical and current workflow contracts', async () => { + const historical = await readFile(join(root, '.github/workflows/phase4-retrieval-observation.yml'), 'utf8'); + const current = await readFile(join(root, '.github/workflows/observation-2.2.yml'), 'utf8'); + assert.match(historical, /ref: d81f9d1351f1a9228650a840629191a92f2dfb22/); + assert.match(historical, /fetch-depth: 0/); + assert.match(current, /fetch-depth: 0/); + assert.match(current, /scripts\/run-retrieval-observation-qualification-2\.2\.mjs/); + assert.match(current, /if: always\(\)/); + assert.match(current, /observation-receipt\.json/); + const runner = await readFile(join(root, 'scripts/run-retrieval-observation-qualification-2.2.mjs'), 'utf8'); + assert.match(runner, /stableJson\(indexed.generation.manifest\) !== stableJson\(expectedManifest\)/); + assert.match(runner, /--is-shallow-repository/); + assert.match(runner, /OBS_SOURCE_PROVENANCE_INVALID/); + assert.doesNotMatch(runner, /error\?\.message \?\? "operational failure"/); +}); + +test('native SQLite initial, one-item reuse, and clean rebuild match independent 2.2 manifests', { timeout: 180000 }, async () => { + await esbuild.build({ entryPoints: [join(root, 'scripts/run-retrieval-observation-qualification-2.2.mjs')], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, 'runner.mjs'), logLevel: 'silent' }); + const runner = await import(pathToFileURL(join(temp, 'runner.mjs'))); + const provider = new runner.ConstantEmbeddingProvider(); + const plan = fixture.performanceSamplePlan(); + const state = join(temp, 'incremental'), clean = join(temp, 'clean'); + await mkdir(state); await mkdir(clean); + const initial = await runner.runIndexPhase(provider, 'initial_index', state, fixture.buildPerformanceCorpus(false), plan.indexing.initial); + const before = runner.readReuseRows(initial.database_path); + const updated = await runner.runIndexPhase(provider, 'incremental_update', state, fixture.buildPerformanceCorpus(true), plan.indexing.incremental_update); + const after = runner.readReuseRows(updated.database_path); + assert.equal(runner.verifyReuseRows(before, after).unchanged_vectors_verified, 9999); + const corrupt = structuredClone(after); corrupt[0].vector_json = '[0,1,0,0]'; + assert.throws(() => runner.verifyReuseRows(before, corrupt), /OBS_UPDATE_REUSE_INVALID/); + const rebuilt = await runner.runIndexPhase(provider, 'clean_rebuild', clean, fixture.buildPerformanceCorpus(true), plan.indexing.clean_rebuild); + assert.deepEqual(rebuilt.manifest, updated.manifest); +}); diff --git a/test/retrieval-observation-qualification.test.mjs b/test/retrieval-observation-qualification.test.mjs index 84bd94a..a2765b5 100644 --- a/test/retrieval-observation-qualification.test.mjs +++ b/test/retrieval-observation-qualification.test.mjs @@ -378,8 +378,10 @@ test("Slice-C temp capability cleans exact roots and fail-retains identity subst } }); -test("Slice-C workflows freeze scheduled Observation and supplementary cross-runtime lanes", async () => { - const observation = await readFile(join(ROOT, ".github", "workflows", "phase4-retrieval-observation.yml"), "utf8"); +test("Slice-C historical workflow bytes and supplementary cross-runtime lanes remain replayable", async () => { + // The original workflow is immutable history. Current dispatch now names the + // exact historical implementation; its successor contract is tested separately. + const observation = execFileSync("git", ["show", "650eab4a6752227cae336d7556a57826c22a0d5a:.github/workflows/phase4-retrieval-observation.yml"], { cwd: ROOT, encoding: "utf8" }); const continuous = await readFile(join(ROOT, ".github", "workflows", "ci.yml"), "utf8"); const bridgeJob = workflowJobBody(continuous, "phase4-retrieval-observation-manual"); const packageJson = JSON.parse(await readFile(join(ROOT, "package.json"), "utf8")); From 306ad69940657dfe82591708a8e05aa787b22944 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 10:20:23 -0400 Subject: [PATCH 03/10] Persist managed MOC no-change audits with native recovery qualification --- .github/workflows/managed-moc-audit-2.2.yml | 47 +++++++ .../v1/change-inventory.json | 40 ++++-- docs/MANAGED-MOC-NO-CHANGE-AUDIT.md | 49 ++++++++ src/navigation-effects/moc-batch.ts | 3 +- src/navigation-effects/node/executor.ts | 75 ++++++++++- src/navigation-effects/node/moc-host.ts | 5 +- src/navigation-effects/planner.ts | 11 +- test/managed-moc-no-change.test.mjs | 118 ++++++++++++++++++ test/navigation-effects-host.test.mjs | 5 +- test/retrieval-observation-2.2.test.mjs | 2 +- 10 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/managed-moc-audit-2.2.yml create mode 100644 docs/MANAGED-MOC-NO-CHANGE-AUDIT.md create mode 100644 test/managed-moc-no-change.test.mjs diff --git a/.github/workflows/managed-moc-audit-2.2.yml b/.github/workflows/managed-moc-audit-2.2.yml new file mode 100644 index 0000000..a43a013 --- /dev/null +++ b/.github/workflows/managed-moc-audit-2.2.yml @@ -0,0 +1,47 @@ +name: Managed MOC 2.2 native audit qualification +on: + push: + branches: [main, 'release/**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-no-change: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + node: [22, 24] + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run typecheck + - name: Native process-exit and filesystem audit qualification + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/moc-audit-evidence" + node -e 'const fs=require("node:fs"); console.log(JSON.stringify({source_commit:process.env.GITHUB_SHA,node:process.version,platform:process.platform,arch:process.arch,filesystem_type:fs.statfsSync(".").type,host_type:"github_hosted",physical_power_loss_qualified:false}))' > "$RUNNER_TEMP/moc-audit-evidence/environment.json" + node --test --test-reporter=tap test/managed-moc-no-change.test.mjs test/navigation-effects-host.test.mjs test/navigation-effects-node.test.mjs > "$RUNNER_TEMP/moc-audit-evidence/tests.tap" 2>&1 + git diff --exit-code + test -z "$(git status --porcelain)" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: moc-audit-${{ matrix.os }}-node-${{ matrix.node }}-${{ github.sha }} + path: ${{ runner.temp }}/moc-audit-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 7f3960c..af2ee89 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -389,6 +389,12 @@ "after": "81cb20749c449c813efc9c10c1a1315a17385e8149b23303ab347482a70d02f2", "rationale": "Runtime modernization and stability: qualify Node 22/24/26 and execute dedicated watcher observations through one bounded exact-latency retry wrapper." }, + { + "path": ".github/workflows/managed-moc-audit-2.2.yml", + "before": null, + "after": "7421258d9380ae8def43300e6f706339318fcf3b543b060759bf73a827663c76", + "rationale": "Run native no-change process-exit, failure and receipt tests on mandatory Linux/Windows Node22/24 with bounded environment/test evidence." + }, { "path": ".github/workflows/observation-2.2.yml", "before": null, @@ -617,6 +623,12 @@ "after": "a34c724c3db22d9eafd13739b38dad0f7fdbb2ce3d6624ace11596a87a6b7dc9", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "docs/MANAGED-MOC-NO-CHANGE-AUDIT.md", + "before": null, + "after": "42b2ec2c53ad2866c3a78e524dfdeac54b5e01b1e633d8f21b16c909ee43ac2f", + "rationale": "Specify separate no-change audit artifact and truthful file-sync/recovery guarantees and remaining durability gates." + }, { "path": "docs/NAVIGATION-EFFECTS-CONTRACT.md", "before": null, @@ -866,14 +878,14 @@ { "path": "src/navigation-effects/moc-batch.ts", "before": null, - "after": "f767587c352d6a9798544d936b931b3236f22290238dad881a63313dc89a6966", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "2ebfc2cc63dcbbee46f8df871defbbd99a4154246c732560c41d7b600256ac45", + "rationale": "Forward explicit no-change audit planning option for the managed Node host." }, { "path": "src/navigation-effects/node/executor.ts", "before": null, - "after": "81525d1b5df21cf7f4c9f6b15cf0963b04188650c4a2ffd91110c430787ca631", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "ece4d38d2c93f58f2a72029253e26cd8fa33cbfb3ba77e82e363589d33fee0be", + "rationale": "Persist separate versioned NO_CHANGE audits before terminal completion; verify on replay, revalidate interrupted no-ops, retain exact failure/refusal semantics." }, { "path": "src/navigation-effects/node/index.ts", @@ -890,8 +902,8 @@ { "path": "src/navigation-effects/node/moc-host.ts", "before": null, - "after": "0de96e4580009ea6e4f771d73205ffb4a7f8b7fe8512ed4cdc5087f2e52e12c3", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "d582ca399ab54cd4c36af6a99495c6c1f1d51805a8028aa5e5ecccebf98cb949", + "rationale": "Execute byte-identical managed plans through durable executor; bind reconciliation identity and suppress false source-change callbacks." }, { "path": "src/navigation-effects/node/moc-runtime.ts", @@ -908,8 +920,8 @@ { "path": "src/navigation-effects/planner.ts", "before": null, - "after": "dbe8ab1b5b72901a1da1a9458af9a7e9dd1f885abd6d9239148002f52d291a4d", - "rationale": "Reviewed Navigation Effects integration: additive, default-disabled implementation, contract, evidence, or qualification coverage; no activation or release authority." + "after": "9ff0726eb113627d22d4bac05d4dd82a8cf54de5285fe4572f2107142773bf46", + "rationale": "Add explicit opt-in durable no-change plan with reconciliation identity while preserving default pure no-op behavior." }, { "path": "src/navigation-effects/types.ts", @@ -1019,6 +1031,12 @@ "after": "702340b218966a17f922ce3af0512f6b09fe2b2b8353ee1bc42ce0e60cdcf3b7", "rationale": "Reviewed Navigation Effects integration: additive, default-disabled implementation, contract, evidence, or qualification coverage; no activation or release authority." }, + { + "path": "test/managed-moc-no-change.test.mjs", + "before": null, + "after": "7b1c4db7185c3096da7df6c299fdc1356e2f1958f8716d9036cdf19fe029a1e4", + "rationale": "Native no-change receipts, idempotency, five process-exit recovery phases, corrupt-audit refusal and actual destination write failure." + }, { "path": "test/navigation-architecture.test.mjs", "before": "b9636738cb6c719e02145386ee5746583a24f878", @@ -1040,8 +1058,8 @@ { "path": "test/navigation-effects-host.test.mjs", "before": null, - "after": "4b8b4fe94280901a598cd5e36088a1b2c304dd428247263d28a759b684147990", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "6f0d3ec51c6bfe414f727f12e5a17339db92f6451177591f98e533a3281273fb", + "rationale": "Assert original journal prefix and exact new no-change terminal sequence rather than expecting absent audits." }, { "path": "test/navigation-effects-node.test.mjs", @@ -1094,7 +1112,7 @@ { "path": "test/retrieval-observation-2.2.test.mjs", "before": null, - "after": "73d368b0d4791bab1eff8bfe0be839ff9b66c3c00b313e685bfcab01d5b49c47", + "after": "621d034de1b2980df66e996fee5365967977cb5c87f49fc228969f077c833e6b", "rationale": "Test fixed pins, fail-closed oracle substitution, workflow separation, and actual native 10k SQLite convergence/reuse." }, { diff --git a/docs/MANAGED-MOC-NO-CHANGE-AUDIT.md b/docs/MANAGED-MOC-NO-CHANGE-AUDIT.md new file mode 100644 index 0000000..4cbe75f --- /dev/null +++ b/docs/MANAGED-MOC-NO-CHANGE-AUDIT.md @@ -0,0 +1,49 @@ +# Managed-MOC durable no-change audit (2.2) + +The Node managed-MOC host explicitly requests audit execution for byte-identical +plans. Pure planning callers retain their existing no-op return by default. +`recordNoChange: true` requires a 64-hex reconciliation run ID. The host derives +that ID from its durable reconciliation intent and host revision. Each operation +binds the exact source snapshot, corpus, policy, configuration, authority and +ownership. Retries of the same execution request retain the same effect ID, +receipt bytes and journal sequence. + +The existing executor performs live authority and target-byte checks under its +target lock. An unchanged operation creates no source replacement, before-image +archive or consumer source-change notification. It persists a separate versioned +`engine.managed-moc-no-change-receipt` beneath `.gkx/effects/no-change/`, then the +existing Effects receipt, then a terminal journal entry binding the Effects +receipt digest. The frozen Effects contract files are unchanged. + +The new receipt contains actor, authority, ownership, source/corpus/configuration +and policy coordinates, exact evaluated-plan digest, `NO_CHANGE` disposition, +timestamp and sequence, resulting source-state digest, reconciliation linkage, +and the storage protocol/result. Its canonical digest seals all fields. Full +source content is excluded. The receipt is authoritative only together with the +matching terminal journal entry and verified plan/Effects receipt; a partial +audit file alone is not durable completion. + +No-op timestamps use the durable journal anchor, so restart does not invent a +different timestamp or rewrite an existing audit. Native process exit is tested +after RECEIVED, PLANNED, PREPARED, audit persistence and Effects receipt +persistence. Recovery revalidates authority and current bytes before completing +an interrupted no-op. Missing/corrupt committed audits refuse startup. Actual +filesystem destination failures retain a recovery-required receipt and never +append COMMITTED for the failed no-op. + +The storage guarantee is exclusive file creation, file sync and readback, with +terminal journal binding. It does not claim directory-entry fsync, hostile +ancestor-swap protection or physical power-loss durability. Partial/corrupt +artifacts are retained for explicit reconciliation; they are not silently +recreated. General executor and host state durability limitations remain in +effect. Comprehensive platform durability and the 24-hour release soak remain +mandatory separate gates. + +Native focused verification: + +```text +npm ci +npm run typecheck +npm run build +node --test test/managed-moc-no-change.test.mjs test/navigation-effects-host.test.mjs test/navigation-effects-node.test.mjs +``` diff --git a/src/navigation-effects/moc-batch.ts b/src/navigation-effects/moc-batch.ts index 9f3804c..0ec3202 100644 --- a/src/navigation-effects/moc-batch.ts +++ b/src/navigation-effects/moc-batch.ts @@ -27,6 +27,7 @@ export async function planManagedMocBatch(input: { authorityEvaluatedAt: string; archiveDate: string; runId: string; + recordNoChange?: boolean; }): Promise<{ corpusDigest: string; results: MocApplyPlanningResult[] }> { const value = structuredClone(input); if (!await verifyVaultNavigationConfig(value.config) || value.snapshot.vaultId !== value.config.vaultId) throw new Error("INVALID_NAVIGATION_CONFIG"); @@ -76,7 +77,7 @@ export async function planManagedMocBatch(input: { unique.add(key); const candidate = candidates.get(t.path); if (!candidate) { results.push({ status: "review-required", targetPath: t.path, reasonCodes: ["NO_DETERMINISTIC_CANDIDATE"] }); continue; } - results.push(await planMocApply({ candidate, currentBytes: t.currentBytes, ownership: t.ownership, authority: t.authority, vaultId: snapshot.vaultId, corpusDigest, policyRef: value.policyRef, authorityEvaluatedAt: value.authorityEvaluatedAt, archiveDate: value.archiveDate, runId: value.runId })); + results.push(await planMocApply({ candidate, currentBytes: t.currentBytes, ownership: t.ownership, authority: t.authority, vaultId: snapshot.vaultId, corpusDigest, policyRef: value.policyRef, authorityEvaluatedAt: value.authorityEvaluatedAt, archiveDate: value.archiveDate, runId: value.runId, recordNoChange: value.recordNoChange })); } return deepFreeze({ corpusDigest, results }); } diff --git a/src/navigation-effects/node/executor.ts b/src/navigation-effects/node/executor.ts index 4a0d5be..d6e1f09 100644 --- a/src/navigation-effects/node/executor.ts +++ b/src/navigation-effects/node/executor.ts @@ -33,6 +33,7 @@ export type NodeEffectFaultPoint = | "after-temporary-write" | "after-replace" | "after-verified" + | "after-no-change-audit" | "after-receipt"; export class SimulatedEffectCrash extends Error { @@ -435,10 +436,16 @@ export class NodeNavigationEffectsExecutor { journalEntryDigest: latest?.entryDigest ?? planDigest, authorityDigest: plan.precondition.authorityDigest, policyRef: { ...plan.policyRef }, - occurredAt: this.clock(), + occurredAt: status === "no-op" && plan.idempotencyKey.startsWith("moc-no-change:") && latest ? latest.occurredAt : this.clock(), reasonCodes, sourceContentIncluded: false, }; + // The additional audit must be durable before even an unsealed success + // alias exists; failures can then retain the ordinary refusal receipt. + if (status === "no-op") { + await this.persistNoChangeAudit(plan, receipt); + if (plan.idempotencyKey.startsWith("moc-no-change:")) await this.fault("after-no-change-audit", plan.effectId); + } const existing = await this.readReceipt(plan.effectId); if (existing) { const withoutOccurredAt = (value: EffectReceipt) => { const copy = { ...value }; delete (copy as Partial).occurredAt; return copy; }; @@ -478,6 +485,57 @@ export class NodeNavigationEffectsExecutor { return resolve(this.stateRoot, "receipts", `${effectFileStem(effectId)}.json`); } + /** Separate versioned audit artifact; the frozen Effects receipt is unchanged. */ + private async noChangeAudit(plan: NavigationEffectPlan, receipt: EffectReceipt) { + const entries = await this.journal.load(); + const anchor = entries.find(entry => entry.entryDigest === receipt.journalEntryDigest); + if (!anchor || anchor.effectId !== plan.effectId || anchor.planDigest !== receipt.planDigest || + receipt.status !== "no-op" || plan.precondition.priorDigest !== plan.proposedDigest || + !plan.ownership || !/^moc-no-change:[0-9a-f]{64}:[0-9a-f]{64}$/.test(plan.idempotencyKey)) throw new Error("NO_CHANGE_AUDIT_CONTEXT_INVALID"); + const material = { + artifactKind: "engine.managed-moc-no-change-receipt", + version: "2.2.0", + operationIdentity: plan.effectId, + idempotencyKey: plan.idempotencyKey, + actor: plan.authority.actor, + authority: plan.authority, + ownership: plan.ownership, + targetPath: plan.targetPath, + sourceDigest: plan.sourceSnapshotDigest, + corpusDigest: plan.corpusDigest, + configurationDigest: plan.configDigest, + policyRef: plan.policyRef, + evaluatedPlanDigest: receipt.planDigest, + disposition: "NO_CHANGE", + occurredAt: receipt.occurredAt, + sequence: anchor.sequence, + journalEntryDigest: anchor.entryDigest, + resultingStateDigest: plan.proposedDigest, + effectReceiptDigest: await canonicalSha256(receipt), + reconciliationDigest: `sha256:${plan.idempotencyKey.split(":")[1]}`, + storageProtocol: "exclusive-file-sync-readback-with-terminal-journal-binding", + durableStorageResult: "FILE_SYNCED_READBACK_VERIFIED", + sourceContentIncluded: false, + }; + return { ...material, receiptDigest: await canonicalSha256(material) }; + } + + private async persistNoChangeAudit(plan: NavigationEffectPlan, receipt: EffectReceipt): Promise { + if (!plan.idempotencyKey.startsWith("moc-no-change:")) return; + const audit = await this.noChangeAudit(plan, receipt); + const path = await this.safeAbsolute(`.gkx/effects/no-change/${effectFileStem(plan.effectId)}.json`, true); + const bytes = canonicalJson(audit) + "\n"; + await this.writeImmutableReceipt(path, bytes, plan.effectId); + if (await readFile(path, "utf8") !== bytes) throw new Error("NO_CHANGE_AUDIT_READBACK_FAILED"); + } + + private async verifyNoChangeAudit(plan: NavigationEffectPlan, receipt: EffectReceipt): Promise { + if (!plan.idempotencyKey.startsWith("moc-no-change:")) return; + const expected = canonicalJson(await this.noChangeAudit(plan, receipt)) + "\n"; + const path = await this.safeAbsolute(`.gkx/effects/no-change/${effectFileStem(plan.effectId)}.json`, true); + if (await readFile(path, "utf8") !== expected) throw new Error("NO_CHANGE_AUDIT_CORRUPT"); + } + private receiptVersionPath(receiptDigest: string): string { if (!/^sha256:[0-9a-f]{64}$/.test(receiptDigest)) throw new Error("RECEIPT_DIGEST_INVALID"); return resolve(this.stateRoot, "receipts", "by-digest", `${receiptDigest.slice(7)}.json`); @@ -681,6 +739,7 @@ export class NodeNavigationEffectsExecutor { if (!structurallyValid || committed.receiptDigest !== await canonicalSha256(receipt)) throw new Error(`RECEIPT_CORRUPT:${plan.effectId}`); const currentReceipt = await this.readReceipt(plan.effectId); if (!currentReceipt || canonicalJson(currentReceipt) !== canonicalJson(receipt)) throw new Error(`RECEIPT_CORRUPT:${plan.effectId}`); + if (expectedStatus === "no-op") await this.verifyNoChangeAudit(plan, receipt); if (expectedStatus === "committed") { const archive = await this.validateArchiveBinding(plan, true); if (!archive.valid || receipt.archiveManifestDigest !== archive.manifestDigest) throw new Error(`ARCHIVE_CORRUPT:${plan.effectId}`); @@ -761,7 +820,9 @@ export class NodeNavigationEffectsExecutor { return { status: "stale", effectId: plan.effectId, receipt, reasonCodes: ["TARGET_PRECONDITION_MISMATCH"] }; } if (beforeDigest === plan.proposedDigest) { + await this.ioFaultInjector?.("receipt", plan.effectId); const receipt = await this.writeReceipt(plan, planDigest, "no-op", beforeDigest, undefined, ["BYTE_IDENTICAL"]); + await this.fault("after-receipt", plan.effectId); await this.journal.append(plan.effectId, "COMMITTED", planDigest, { reasonCode: "BYTE_IDENTICAL", receiptDigest: await canonicalSha256(receipt) }); return { status: "no-op", effectId: plan.effectId, receipt, reasonCodes: ["BYTE_IDENTICAL"] }; } @@ -980,6 +1041,18 @@ export class NodeNavigationEffectsExecutor { proposedDigest: plan.proposedDigest, }; + if (targetDigest === plan.proposedDigest && plan.precondition.priorDigest === plan.proposedDigest && + plan.idempotencyKey.startsWith("moc-no-change:") && temporary === null && + ["RECEIVED", "PLANNED", "PREPARED"].includes(latest.state)) { + if (!this.preconditionValidator || (await this.preconditionValidator(plan)).length) { + results.push({ artifactKind: "engine.navigation-effect-recovery-result", effectsContract: "1.0.0", effectId, classification: "ambiguous-or-corrupt", writeCapabilityMayEnable: false, reasonCodes: ["NO_CHANGE_AUTHORITY_REVALIDATION_FAILED"], observed }); + continue; + } + const receipt = await this.writeReceipt(plan, planDigest, "no-op", plan.proposedDigest, undefined, ["BYTE_IDENTICAL"]); + await this.journal.append(effectId, "COMMITTED", planDigest, { reasonCode: "BYTE_IDENTICAL", receiptDigest: await canonicalSha256(receipt) }); + results.push({ artifactKind: "engine.navigation-effect-recovery-result", effectsContract: "1.0.0", effectId, classification: "effect-present-verified", writeCapabilityMayEnable: true, reasonCodes: ["NO_CHANGE_RECOVERED"], observed }); + continue; + } if (targetDigest === plan.proposedDigest) { if (!archiveValid) { await this.journal.append(effectId, "RECOVERY_REQUIRED", planDigest, { reasonCode: "ARCHIVE_BEFORE_INVALID" }); diff --git a/src/navigation-effects/node/moc-host.ts b/src/navigation-effects/node/moc-host.ts index 5d695cb..4fbbea9 100644 --- a/src/navigation-effects/node/moc-host.ts +++ b/src/navigation-effects/node/moc-host.ts @@ -132,7 +132,7 @@ export class NodeManagedMocHost { if (!parsed.ok) throw new Error("HOST_RECOVERY_MARKERS_INVALID"); binding.generatedRegion = parsed.region; } - await this.options.onCommitted?.(pending.path, pending.proposedDigest, pending.effectId); + if (terminal.reasonCode !== "BYTE_IDENTICAL") await this.options.onCommitted?.(pending.path, pending.proposedDigest, pending.effectId); await this.mutate(s => { s.ownership[pending.path] = binding; s.pending = null; }); } else if (!terminal || terminal.state === "ABORTED" || terminal.state === "STALE") { await this.mutate(s => { s.pending = null; }); @@ -151,7 +151,8 @@ export class NodeManagedMocHost { } const targets = await Promise.all(context.targets.map(async t => ({ ...t, ownership: t.ownership.ownership === "unmanaged" ? t.ownership : (this.state!.ownership[t.path] ?? t.ownership), currentBytes: await this.executor.readSource(t.path) }))); const now = this.clock(); - const batch = await planManagedMocBatch({ ...context, targets, authorityEvaluatedAt: now, archiveDate: now.slice(0, 10), runId: randomUUID() }); + const reconciliationDigest = await canonicalSha256({ intent, hostRevision: this.state!.revision }); + const batch = await planManagedMocBatch({ ...context, targets, authorityEvaluatedAt: now, archiveDate: now.slice(0, 10), runId: reconciliationDigest.slice(7), recordNoChange: true }); // A denied target blocks the batch before any new effect, preserving review state. if (batch.results.some(r => r.status !== "planned" && r.status !== "no-op")) throw new Error("MOC_BATCH_REQUIRES_REVIEW"); for (const result of batch.results) { diff --git a/src/navigation-effects/planner.ts b/src/navigation-effects/planner.ts index 54041ae..c2632e7 100644 --- a/src/navigation-effects/planner.ts +++ b/src/navigation-effects/planner.ts @@ -53,6 +53,8 @@ export async function planMocApply(input: { authorityEvaluatedAt: string; archiveDate: string; runId: string; + /** Explicit host audit lane: byte-identical plans still require durable execution. */ + recordNoChange?: boolean; }): Promise { const targetValidation = validateVaultRelativePath(input.candidate.targetPath); const targetPath = targetValidation.normalized ?? normalizeVaultRelative(input.candidate.targetPath); @@ -102,7 +104,9 @@ export async function planMocApply(input: { } const proposedDigest = await sha256Bytes(proposedBytes); - if (currentDigest === proposedDigest) return deepFreeze({ status: "no-op", targetPath, currentDigest, proposedDigest, reasonCodes: ["BYTE_IDENTICAL"] }); + const noChange = currentDigest === proposedDigest; + if (noChange && input.recordNoChange && !/^[0-9a-f]{64}$/.test(input.runId)) return denied(targetPath, "NO_CHANGE_RECONCILIATION_ID_INVALID"); + if (noChange && !input.recordNoChange) return deepFreeze({ status: "no-op", targetPath, currentDigest, proposedDigest, reasonCodes: ["BYTE_IDENTICAL"] }); const archiveRunPath = canonicalMocArchiveRunPath(input.archiveDate, input.runId); const authorityDigest = await canonicalSha256(input.authority); const precondition = input.currentBytes === null @@ -119,12 +123,13 @@ export async function planMocApply(input: { policyRef: input.policyRef, authorityDigest, precondition, + ...(noChange ? { reconciliationRunId: input.runId } : {}), }); const plan: NavigationEffectPlan = { artifactKind: "engine.navigation-effect-plan", effectsContract: NAVIGATION_EFFECTS_CONTRACT_VERSION, effectId: `effect:${identity.slice(7, 39)}`, - idempotencyKey: `moc:${identity.slice(7)}`, + idempotencyKey: noChange ? `moc-no-change:${input.runId}:${identity.slice(7)}` : `moc:${identity.slice(7)}`, operation: input.currentBytes === null ? "moc:create" : "moc:replace", vaultId: input.vaultId, targetPath, @@ -136,7 +141,7 @@ export async function planMocApply(input: { authority: { ...input.authority, actor: { ...input.authority.actor }, policyRef: { ...input.authority.policyRef } }, precondition, ownership: { ...input.ownership, generatedRegion: input.ownership.generatedRegion ? { ...input.ownership.generatedRegion } : undefined }, - archiveRunPath, + ...(noChange ? {} : { archiveRunPath }), }; return deepFreeze({ status: "planned", diff --git a/test/managed-moc-no-change.test.mjs b/test/managed-moc-no-change.test.mjs new file mode 100644 index 0000000..c525c47 --- /dev/null +++ b/test/managed-moc-no-change.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NodeManagedMocHost, NodeNavigationEffectsExecutor } from '../dist/navigation-effects-node.mjs'; +import { planManagedMocBatch } from '../dist/navigation-effects.mjs'; +import { buildVaultNavigationConfig } from '../dist/navigation.mjs'; +import { canonicalSha256 } from '../dist/gkos-engine.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'gkos-no-change-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'topics')); + const policyRef = { id: 'policy', version: '1', digest: 'sha256:'+'b'.repeat(64) }; + const config = await buildVaultNavigationConfig({ configId: '01990ac0-0000-7000-8000-000000000001', version: 1, vaultId: 'vault', promotedMocNames: [], createdAt: '2026-09-06T12:00:00Z', createdBy: 'owner', policy: policyRef }); + const context = { snapshot: { vaultId: 'vault', sources: [{ relativePath: 'topics/a.md', content: 'A', title: 'A', sensitivity: 'public' }] }, config, policyRef, allowedSensitivities: ['public'], targets: [{ path: 'topics/index.md', ownership: { targetPath: 'topics/index.md', ownership: 'fully-managed', creationAuthorized: true }, authority: { actor: { actorId: 'owner', actorType: 'human' }, grantId: 'grant', allowedRoot: 'topics', capability: 'moc:apply', sensitivityCeiling: 'public', policyRef } }] }; + const options = { vaultRoot: root, pathThreatModel: 'cooperative-vault', snapshot: async () => context, validatePreconditions: () => [], clock: () => '2026-09-08T12:00:00Z' }; + const host = new NodeManagedMocHost(options); + await host.start(0); await host.shutdown(); + const state = JSON.parse(await readFile(join(root, '.gkx/effects/moc-host.json'), 'utf8')); + context.targets[0].ownership = state.state.ownership['topics/index.md']; + context.targets[0].currentBytes = await readFile(join(root, 'topics/index.md'), 'utf8'); + const batch = await planManagedMocBatch({ ...context, recordNoChange: true, authorityEvaluatedAt: '2026-09-08T12:00:00Z', archiveDate: '2026-09-08', runId: 'a'.repeat(64) }); + assert.equal(batch.results[0].status, 'planned'); + const planned = batch.results[0]; + assert.equal(planned.plan.precondition.priorDigest, planned.plan.proposedDigest); + assert.equal(planned.plan.archiveRunPath, undefined); + return { root, context, options, request: { plan: planned.plan, proposedBytes: planned.proposedBytes } }; +} +const executor = root => new NodeNavigationEffectsExecutor({ vaultRoot: root, pathThreatModel: 'cooperative-vault', preconditionValidator: () => [] }); +const auditPath = (root, id) => join(root, '.gkx/effects/no-change', id.replaceAll(':','_')+'.json'); + +test('unchanged execution stores fully bound NO_CHANGE and exact retries preserve receipt/sequence/source bytes', async t => { + const { root, request } = await fixture(t); + const target = join(root, 'topics/index.md'), before = await stat(target); + const e = executor(root); t.after(() => e.shutdown()); + const result = await e.execute(request); + assert.equal(result.status, 'no-op'); + const auditBytes = await readFile(auditPath(root, request.plan.effectId), 'utf8'); + const audit = JSON.parse(auditBytes), { receiptDigest, ...material } = audit; + assert.equal(receiptDigest, await canonicalSha256(material)); + assert.equal(audit.disposition, 'NO_CHANGE'); + assert.deepEqual(audit.actor, request.plan.authority.actor); + assert.deepEqual(audit.authority, request.plan.authority); + assert.equal(await canonicalSha256(audit.ownership), await canonicalSha256(request.plan.ownership)); + assert.equal(audit.sourceDigest, request.plan.sourceSnapshotDigest); + assert.equal(audit.configurationDigest, request.plan.configDigest); + assert.deepEqual(audit.policyRef, request.plan.policyRef); + assert.equal(audit.evaluatedPlanDigest, await canonicalSha256(request.plan)); + assert.equal(audit.resultingStateDigest, request.plan.proposedDigest); + assert.equal(audit.reconciliationDigest, 'sha256:'+'a'.repeat(64)); + const journal = await e.journal.load(); + assert.equal(journal.at(-1).receiptDigest, audit.effectReceiptDigest); + assert.equal(journal.at(-2).sequence, audit.sequence); + const retry = await e.execute(request); + assert.deepEqual(retry.reasonCodes, ['IDEMPOTENT_REPLAY']); + assert.deepEqual(await e.journal.load(), journal); + assert.equal(await readFile(auditPath(root, request.plan.effectId), 'utf8'), auditBytes); + assert.equal((await stat(target)).mtimeMs, before.mtimeMs); + assert.equal(await readFile(target, 'utf8'), request.proposedBytes); +}); + +for (const point of ['after-received', 'after-planned', 'after-prepared', 'after-no-change-audit', 'after-receipt']) { + test(`native process exit during no-change ${point} recovers one verified receipt`, async t => { + const { root, request } = await fixture(t); + const input = join(root, 'request.json'); await writeFile(input, JSON.stringify(request)); + const moduleUrl = new URL('../dist/navigation-effects-node.mjs', import.meta.url).href; + const script = `import {readFileSync} from 'node:fs'; import {NodeNavigationEffectsExecutor} from ${JSON.stringify(moduleUrl)}; const e=new NodeNavigationEffectsExecutor({vaultRoot:process.argv[1],pathThreatModel:'cooperative-vault',preconditionValidator:()=>[],faultInjector:p=>{if(p===process.argv[3])process.exit(86)}}); await e.execute(JSON.parse(readFileSync(process.argv[2],'utf8'))); process.exit(87);`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script, root, input, point], { encoding: 'utf8', timeout: 30000 }); + assert.equal(child.status, 86, child.stderr); + const recovered = executor(root); t.after(() => recovered.shutdown()); + const recovery = await recovered.recoverStartup(); + assert.equal(recovery.safeToEnableWrites, true); + const result = await recovered.execute(request); + assert.equal(result.status, 'no-op'); + const entries = (await recovered.journal.load()).filter(row => row.effectId === request.plan.effectId); + assert.equal(entries.filter(row => row.state === 'COMMITTED').length, 1); + assert.equal((await readdir(join(root, '.gkx/effects/no-change'))).length, 1); + assert.equal(JSON.parse(await readFile(auditPath(root, request.plan.effectId), 'utf8')).disposition, 'NO_CHANGE'); + }); +} + +test('missing or corrupt no-change audit blocks startup rather than reconstructing historical success', async t => { + const { root, request } = await fixture(t); + const e = executor(root); await e.execute(request); await e.shutdown(); + await writeFile(auditPath(root, request.plan.effectId), '{}\n'); + const restarted = executor(root); + await assert.rejects(restarted.recoverStartup(), /NO_CHANGE_AUDIT_CORRUPT/); + await restarted.releaseVaultLease(); +}); + +test('unchanged managed MOC still revalidates live authority and retains unresolved intent on refusal', async t => { + const { root, options } = await fixture(t); + const host = new NodeManagedMocHost({ ...options, validatePreconditions: () => ['REVOKED'] }); + t.after(() => host.shutdown()); + await assert.rejects(host.start(1), /MOC_EFFECT_NOT_COMMITTED/); + const state = JSON.parse(await readFile(join(root, '.gkx/effects/moc-host.json'), 'utf8')); + assert.ok(state.state.intent); + const entries = await host.executor.journal.load(); + assert.equal(entries.at(-1).state, 'ABORTED'); + assert.equal(entries.at(-1).reasonCode, 'REVOKED'); +}); + +test('native audit destination write failure cannot seal NO_CHANGE or promote an operation', async t => { + const { root, request } = await fixture(t); + // Safely provoke an actual filesystem ENOTDIR/EEXIST without filling a disk. + await writeFile(join(root, '.gkx/effects/no-change'), 'blocked audit destination'); + const e = executor(root); t.after(() => e.shutdown()); + const result = await e.execute(request); + assert.equal(result.status, 'recovery-required'); + assert.deepEqual(result.reasonCodes, ['EXECUTION_FAILURE']); + const entries = (await e.journal.load()).filter(row => row.effectId === request.plan.effectId); + assert.equal(entries.some(row => row.state === 'COMMITTED'), false); + assert.equal(entries.at(-1).state, 'RECOVERY_REQUIRED'); + assert.equal(await readFile(join(root, 'topics/index.md'), 'utf8'), request.proposedBytes); +}); diff --git a/test/navigation-effects-host.test.mjs b/test/navigation-effects-host.test.mjs index c0774a7..59f4d13 100644 --- a/test/navigation-effects-host.test.mjs +++ b/test/navigation-effects-host.test.mjs @@ -145,7 +145,10 @@ test('periodic and startup reconciliation repair changes with no delivered event const journalBeforeNoop = await host.executor.journal.load(); await host.coordinator.requestReconciliation(301_000); await host.coordinator.tick(301_750); - assert.deepEqual(await host.executor.journal.load(), journalBeforeNoop); + const journalAfterNoop = await host.executor.journal.load(); + assert.deepEqual(journalAfterNoop.slice(0, journalBeforeNoop.length), journalBeforeNoop); + assert.deepEqual(journalAfterNoop.slice(journalBeforeNoop.length).map(row => row.state), ['RECEIVED', 'PLANNED', 'PREPARED', 'COMMITTED']); + assert.equal(journalAfterNoop.at(-1).reasonCode, 'BYTE_IDENTICAL'); await host.shutdown(); context.snapshot.sources[0].title = 'Changed while offline'; diff --git a/test/retrieval-observation-2.2.test.mjs b/test/retrieval-observation-2.2.test.mjs index 4390525..a3d2598 100644 --- a/test/retrieval-observation-2.2.test.mjs +++ b/test/retrieval-observation-2.2.test.mjs @@ -62,7 +62,7 @@ test('native SQLite initial, one-item reuse, and clean rebuild match independent const provider = new runner.ConstantEmbeddingProvider(); const plan = fixture.performanceSamplePlan(); const state = join(temp, 'incremental'), clean = join(temp, 'clean'); - await mkdir(state); await mkdir(clean); + await mkdir(state, { mode: 0o700 }); await mkdir(clean, { mode: 0o700 }); const initial = await runner.runIndexPhase(provider, 'initial_index', state, fixture.buildPerformanceCorpus(false), plan.indexing.initial); const before = runner.readReuseRows(initial.database_path); const updated = await runner.runIndexPhase(provider, 'incremental_update', state, fixture.buildPerformanceCorpus(true), plan.indexing.incremental_update); From 458a23f3771ae7b303cf03956727d0b0a7eed98a Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 10:26:12 -0400 Subject: [PATCH 04/10] Gate immutable npm publication on exact approved source and artifact evidence --- .github/workflows/npm-release-2.2.yml | 108 ++++++++++++++++++ .github/workflows/sidecar-release.yml | 4 +- .../v1/change-inventory.json | 28 ++++- docs/NPM-2.2-OWNER-ACTIONS.md | 52 +++++++++ scripts/release-220-preflight.mjs | 85 ++++++++++++++ test/release-220-preflight.test.mjs | 33 ++++++ 6 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/npm-release-2.2.yml create mode 100644 docs/NPM-2.2-OWNER-ACTIONS.md create mode 100644 scripts/release-220-preflight.mjs create mode 100644 test/release-220-preflight.test.mjs diff --git a/.github/workflows/npm-release-2.2.yml b/.github/workflows/npm-release-2.2.yml new file mode 100644 index 0000000..df81b8b --- /dev/null +++ b/.github/workflows/npm-release-2.2.yml @@ -0,0 +1,108 @@ +name: Immutable Engine 2.2 npm release +on: + push: + tags: [v2.2.0] +permissions: + contents: read + actions: read + id-token: write +concurrency: + group: gkos-engine-2.2.0-publication + cancel-in-progress: false +jobs: + publish: + if: github.repository == 'Odenknight/GKOS-Engine' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + environment: gkos-engine-release + env: + GKOS_220_APPROVAL_JSON: ${{ vars.GKOS_220_APPROVAL_JSON }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + ref: refs/tags/v2.2.0 + fetch-depth: 0 + path: candidate + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + repository: Odenknight/gkos-standard + ref: ad10dfe94a024f464430fd243c5a918d03389041 + path: gkos-standard + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: 24 + cache: npm + cache-dependency-path: candidate/package-lock.json + - name: Exact approved source and signed annotated tag + working-directory: candidate + env: + ALLOWED_SIGNERS: ${{ vars.GKOS_RELEASE_ALLOWED_SIGNERS }} + run: | + set -euo pipefail + mkdir -m 700 "$RUNNER_TEMP/release-evidence" + printf '%s\n' "$ALLOWED_SIGNERS" > "$RUNNER_TEMP/release-evidence/allowed-signers" + git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile="$RUNNER_TEMP/release-evidence/allowed-signers" verify-tag v2.2.0 + node scripts/release-220-preflight.mjs > "$RUNNER_TEMP/release-evidence/preflight.json" + - name: Install current trusted-publishing CLI + run: npm install --global npm@12.0.2 --registry=https://registry.npmjs.org + - name: Retrieve the exact approved qualification bundle + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + artifact_id="$(node -e 'console.log(JSON.parse(process.env.GKOS_220_APPROVAL_JSON).evidenceArtifactId)')" + gh api "repos/Odenknight/GKOS-Engine/actions/artifacts/$artifact_id" > "$RUNNER_TEMP/release-evidence/qualified-artifact-metadata.json" + node -e 'const fs=require("node:fs"),assert=require("node:assert/strict"),r=JSON.parse(process.env.GKOS_220_APPROVAL_JSON),m=JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/release-evidence/qualified-artifact-metadata.json"));assert.equal(m.expired,false);assert.equal(m.workflow_run.head_sha,r.sourceCommit);' + gh api "repos/Odenknight/GKOS-Engine/actions/artifacts/$artifact_id/zip" > "$RUNNER_TEMP/release-evidence/qualified-evidence.zip" + - name: Refuse existing version and verify registry maintainer + working-directory: candidate + run: | + set -euo pipefail + npm view gkos-engine versions --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/versions.json" + npm view gkos-engine dist-tags --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/dist-tags.json" + npm view gkos-engine maintainers --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/maintainers.json" + node --input-type=module -e 'import fs from "node:fs"; import assert from "node:assert/strict"; const root=process.env.RUNNER_TEMP+"/release-evidence/"; assert.ok(!JSON.parse(fs.readFileSync(root+"versions.json")).includes("2.2.0")); assert.ok(JSON.parse(fs.readFileSync(root+"maintainers.json")).some(x=>x.startsWith("odenknight <")));' + - name: Rerun release gates serially + working-directory: candidate + run: | + set -euo pipefail + npm ci + npm run typecheck + npm run build + npm test + npm run test:navigation + npm run test:intelligence + npm run pack:check + npm run check:license + npm run check:nomenclature + npm audit --audit-level=low + npm run qualify:current -- --output "$RUNNER_TEMP/current-qualification" + git diff --exit-code + test -z "$(git status --porcelain)" + - name: Create and inspect the one publication artifact + working-directory: candidate + run: | + set -euo pipefail + npm pack --dry-run --ignore-scripts --json > "$RUNNER_TEMP/release-evidence/pack-dry-run.json" + npm pack --ignore-scripts --json --pack-destination "$RUNNER_TEMP/release-evidence" > "$RUNNER_TEMP/release-evidence/pack.json" + node scripts/release-220-preflight.mjs --artifact "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" "$RUNNER_TEMP/release-evidence/pack.json" > "$RUNNER_TEMP/release-evidence/artifact.json" + npm sbom --sbom-format=cyclonedx > "$RUNNER_TEMP/release-evidence/sbom.cdx.json" + npm publish "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" --dry-run --ignore-scripts --registry=https://registry.npmjs.org --tag=latest --access=public + - name: Publish inspected artifact through OIDC only + working-directory: candidate + run: | + set -euo pipefail + npm view gkos-engine versions --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/versions-immediate.json" + node -e 'const fs=require("node:fs"); if(JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/release-evidence/versions-immediate.json")).includes("2.2.0"))process.exit(1)' + node scripts/release-220-preflight.mjs --artifact "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" "$RUNNER_TEMP/release-evidence/pack.json" + npm publish "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" --ignore-scripts --registry=https://registry.npmjs.org --tag=latest --access=public + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: gkos-engine-2.2.0-release-evidence-${{ github.run_id }} + path: ${{ runner.temp }}/release-evidence/ + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/sidecar-release.yml b/.github/workflows/sidecar-release.yml index 3112216..a8313e8 100644 --- a/.github/workflows/sidecar-release.yml +++ b/.github/workflows/sidecar-release.yml @@ -53,7 +53,9 @@ jobs: cache: npm - name: Verify release identity - if: startsWith(github.ref, 'refs/tags/') + # The stable 2.2 release notes/evidence are created only after npm and + # consumer verification. Keep these binaries as run artifacts meanwhile. + if: startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/tags/v2.2.0' shell: bash run: | set -euo pipefail diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index af2ee89..137ee14 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -395,6 +395,12 @@ "after": "7421258d9380ae8def43300e6f706339318fcf3b543b060759bf73a827663c76", "rationale": "Run native no-change process-exit, failure and receipt tests on mandatory Linux/Windows Node22/24 with bounded environment/test evidence." }, + { + "path": ".github/workflows/npm-release-2.2.yml", + "before": null, + "after": "8005378fc1e466eda9f5b61c048179d455c934073be964be5c9960c1d33c0ccc", + "rationale": "Add immutable exact-tag OIDC-only release workflow that refuses absent approval/evidence and mismatched source or tarball; no release triggered." + }, { "path": ".github/workflows/observation-2.2.yml", "before": null, @@ -416,8 +422,8 @@ { "path": ".github/workflows/sidecar-release.yml", "before": "4e8b5e5eb49ceabcfdf048b3d3cf7163725e2554", - "after": "68209099200a7152b8b95a66dba36fc381124e535fb1326c886ea2a73d020bcb", - "rationale": "Release stability: build SEA artifacts on Node 24 LTS and bind checkout and setup-node to reviewed v5 commits." + "after": "d9effe53e0f61158b3238a4f70c41dfa07e464f43bf86c4aeebbdd3f468151a1", + "rationale": "Preserve binary builds and artifacts; prevent v2.2.0 sidecar uploads from prematurely creating or rewriting official stable release notes." }, { "path": ".gitignore", @@ -635,6 +641,12 @@ "after": "6514c8e48174017a248960b5a1512739c8416abbb76b5b766429e6dc54913c29", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "docs/NPM-2.2-OWNER-ACTIONS.md", + "before": null, + "after": "b072ebe666d141bf43990cca008d377e8f70b4efcd7c849398562ca83d00b7d3", + "rationale": "Provide exact npm trusted-publisher and protected GitHub environment setup; preserve absent owner-account and final qualification blockers." + }, { "path": "docs/OBSERVATION-2.2-QUALIFICATION.md", "before": null, @@ -785,6 +797,12 @@ "after": "d963d6affcc45878b6007c2b5fe052e76a08218019ce720b0db1cea4e77ca56b", "rationale": "Independently construct 2.2 projection preimages without production indexing or canonicalization." }, + { + "path": "scripts/release-220-preflight.mjs", + "before": null, + "after": "d11911dc9445686974dd73df407edb53a08d0286cbb59c3a1f0253d0745d60a2", + "rationale": "Fail closed on any missing mandatory exact-commit gate, incomplete soak, missing evidence binding, wrong tag or inspected tarball digest/inventory." + }, { "path": "scripts/run-current-tests.mjs", "before": null, @@ -1091,6 +1109,12 @@ "after": "4d0634d1ab57f5c9877e9c04a118274bc2afddba11e3a0758cc6feced56fcd0e", "rationale": "Reviewed Navigation Effects integration: additive, default-disabled implementation, contract, evidence, or qualification coverage; no activation or release authority." }, + { + "path": "test/release-220-preflight.test.mjs", + "before": null, + "after": "b71c27236f755d6ccfeb087839e3336b77c3763efe92c42fc69d93ad1a767e0e", + "rationale": "Negative and positive preflight tests for mandatory gates, source identity, soak length, archive digests and package inventory." + }, { "path": "test/retrieval-evaluation-cli.test.mjs", "before": "80d2df55e3c6c97e95b08101a1e892ef63c43da7", diff --git a/docs/NPM-2.2-OWNER-ACTIONS.md b/docs/NPM-2.2-OWNER-ACTIONS.md new file mode 100644 index 0000000..bfe2f60 --- /dev/null +++ b/docs/NPM-2.2-OWNER-ACTIONS.md @@ -0,0 +1,52 @@ +# Engine 2.2 trusted publication setup + +Publication is blocked until the entire release inventory passes at one exact +commit. The workflow alone does not establish that qualification. There is no +token fallback. npm currently lists 2.0.1 as latest; recheck immediately before +publication. Local `npm whoami` returned E401 on September 8. + +Owner account checklist: + +- Sign into npmjs.com as the owner/maintainer of the unscoped public + `gkos-engine` package. Run `npm whoami` from that authenticated owner session + and retain its result with the qualification evidence. Do not save credentials + in repository files. +- In package Settings → Trusted publishing, add GitHub Actions with owner + `Odenknight`, repository `GKOS-Engine`, workflow filename + `npm-release-2.2.yml`, environment `gkos-engine-release`. +- Keep account 2FA and package publishing protection enabled. Confirm the exact + trusted-publisher match. No alternative npm token should take over on failure. +- Configure GitHub environment `gkos-engine-release` with required reviewer + Odenknight and only the protected tag `v2.2.0`. Protect that tag against update + and deletion. Confirm intended branch/tag policy before any tag push. +- Set environment `GKOS_RELEASE_ALLOWED_SIGNERS` to the approved SSH tag-signing + principal and public key. Set `GKOS_220_APPROVAL_JSON` only after reviewing + the exact candidate and all evidence. Its schema is validated by + `scripts/release-220-preflight.mjs`; do not fabricate PASS entries for gaps. + +The approval record identifies the exact source commit, tarball SHA-256 and +SHA-512 integrity, canonical file-inventory digest, immutable GitHub Actions +evidence artifact ID and ZIP digest, and each named gate's bound receipt digest +and evidence URL. The soak must cover at least 86,400 seconds with zero unexplained +failures. Experimental Node 26 is not substituted for mandatory Node 22/24. +The record is external to the candidate to avoid self-referential commit or +tarball hashes. Source changes invalidate it. + +`npm-release-2.2.yml` requires an annotated SSH-signed v2.2.0 tag, verifies it +against the protected signer record and approved commit, downloads the exact +qualified evidence bundle, reruns release gates, packs once, checks both digests +and the bounded inventory, dry-runs publication, then publishes that same file +to the public registry with `latest`. Missing approval/signers, existing 2.2.0, +dirty source, mismatched evidence or artifact all refuse publication. GitHub's +older sidecar workflow also reacts to version tags; its behavior must be +reconciled in the final release review before the tag is pushed. + +npm Trusted Publishing requires compatible Node/npm versions and automatically +generates provenance. This workflow pins npm 12.0.2 on Node 24; it does not inject +a provenance substitute. See [npm trusted-publisher documentation](https://docs.npmjs.com/trusted-publishers/). +Local owner-account verification is separate from the job's OIDC publish +identity; a local E401 must not be relabeled a successful owner check. + +After publication, compare registry integrity/tarball bytes and run fresh Linux +and Windows consumer smoke tests before recording a successful release or +updating Kosmos. Never move the tag or attempt to reuse an npm version. diff --git a/scripts/release-220-preflight.mjs b/scripts/release-220-preflight.mjs new file mode 100644 index 0000000..1e915ca --- /dev/null +++ b/scripts/release-220-preflight.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const REQUIRED_GATES = Object.freeze([ + 'source-build-package', 'linux-node22', 'linux-node24', + 'windows-node22', 'windows-node24', 'observation-2.2', + 'historical-observation-2.1.2', 'managed-moc-no-change', + 'native-linux-durability', 'native-windows-durability', + 'performance-all-tiers', 'soak-24h', 'consumer-linux', 'consumer-windows', + 'kosmos-exact-candidate', 'npm-owner-account', +]); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const hex = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +export function validateApproval(record, actualCommit) { + assert.equal(record?.schema, 'gkos-engine-release-approval/2.2.0'); + assert.equal(record.package, 'gkos-engine'); + assert.equal(record.version, '2.2.0'); + assert.equal(record.tag, 'v2.2.0'); + assert.match(record.sourceCommit, /^[0-9a-f]{40}$/); + assert.equal(record.sourceCommit, actualCommit); + assert.ok(hex(record.tarballSha256)); + assert.match(record.tarballIntegrity, /^sha512-[A-Za-z0-9+/]{86}==$/); + assert.ok(hex(record.fileInventorySha256)); + assert.ok(hex(record.evidenceBundleSha256)); + assert.ok(Number.isSafeInteger(record.evidenceArtifactId) && record.evidenceArtifactId > 0); + assert.equal(record.ownerNpmLogin, 'odenknight'); + assert.deepEqual(record.gates.map(row => row.name).sort(), [...REQUIRED_GATES].sort()); + for (const gate of record.gates) { + assert.equal(gate.status, 'PASS', gate.name); + assert.equal(gate.sourceCommit, record.sourceCommit, gate.name); + assert.ok(hex(gate.receiptSha256), gate.name); + assert.match(gate.evidenceUrl, /^https:\/\/github\.com\/Odenknight\//, gate.name); + } + const soak = record.gates.find(row => row.name === 'soak-24h'); + assert.ok(Number.isSafeInteger(soak.durationSeconds) && soak.durationSeconds >= 86400); + assert.equal(soak.unexplainedFailures, 0); + return record; +} +export function verifyArtifact(record, bytes, packReport) { + assert.equal(sha256(bytes), record.tarballSha256); + assert.equal('sha512-' + createHash('sha512').update(bytes).digest('base64'), record.tarballIntegrity); + assert.equal(packReport.name, 'gkos-engine'); assert.equal(packReport.version, '2.2.0'); + assert.equal(packReport.filename, 'gkos-engine-2.2.0.tgz'); + assert.ok(packReport.size > 0 && packReport.size < 32 * 1024 * 1024); + assert.ok(packReport.unpackedSize > 0 && packReport.unpackedSize < 128 * 1024 * 1024); + const files = packReport.files.map(row => ({ path: row.path, size: row.size, mode: row.mode })).sort((a,b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + assert.ok(files.length > 0 && files.length < 2048); + assert.equal(new Set(files.map(row => row.path)).size, files.length); + for (const row of files) { + assert.ok(!row.path.startsWith('/') && !row.path.split('/').includes('..')); + assert.ok(!/(?:^|\/)(?:\.git|\.gkx|\.env(?:\..*)?|\.npmrc|node_modules|__pycache__|\.cache|\.tmp|\.work|private)(?:\/|$)/i.test(row.path)); + assert.ok(!/\.(?:pem|key|sqlite|sqlite3|db|log|pyc|pyo)$/i.test(row.path)); + } + assert.equal(sha256(JSON.stringify(files)), record.fileInventorySha256); + return { filename: packReport.filename, sha256: record.tarballSha256, integrity: record.tarballIntegrity, fileCount: files.length, fileInventorySha256: record.fileInventorySha256 }; +} +function git(...args) { return execFileSync('git', args, { encoding:'utf8' }).trim(); } +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const head = git('rev-parse','HEAD'); + const record = validateApproval(JSON.parse(process.env.GKOS_220_APPROVAL_JSON ?? 'null'), head); + assert.ok(!process.env.NODE_AUTH_TOKEN && !process.env.NPM_TOKEN); + assert.equal(git('rev-parse','--is-shallow-repository'), 'false'); + assert.equal(git('status','--porcelain','--untracked-files=all'), ''); + assert.equal(process.env.GITHUB_REF, 'refs/tags/v2.2.0'); + assert.equal(process.env.GITHUB_SHA, head); + assert.equal(git('cat-file','-t','refs/tags/v2.2.0'), 'tag'); + assert.equal(git('rev-parse','refs/tags/v2.2.0^{commit}'), head); + assert.equal(JSON.parse(readFileSync('package.json','utf8')).version, '2.2.0'); + git('merge-base','--is-ancestor',head,'origin/main'); + if (process.argv[2] === '--artifact') { + assert.equal(sha256(readFileSync(resolve(process.env.RUNNER_TEMP, 'release-evidence', 'qualified-evidence.zip'))), record.evidenceBundleSha256); + const reports = JSON.parse(readFileSync(process.argv[4], 'utf8')); + assert.equal(reports.length, 1); + console.log(JSON.stringify(verifyArtifact(record, readFileSync(process.argv[3]), reports[0]))); + } else { + assert.equal(process.argv.length, 2); + console.log(JSON.stringify({ status:'PASS', sourceCommit:head, approvalSha256:sha256(process.env.GKOS_220_APPROVAL_JSON) })); + } + } catch { console.error('RELEASE_220_PREFLIGHT_REFUSED'); process.exitCode=1; } +} diff --git a/test/release-220-preflight.test.mjs b/test/release-220-preflight.test.mjs new file mode 100644 index 0000000..641687b --- /dev/null +++ b/test/release-220-preflight.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { REQUIRED_GATES, validateApproval, verifyArtifact } from '../scripts/release-220-preflight.mjs'; +const commit = 'a'.repeat(40), hash = 'b'.repeat(64); +function approval() { + return { schema:'gkos-engine-release-approval/2.2.0',package:'gkos-engine',version:'2.2.0',tag:'v2.2.0',sourceCommit:commit,tarballSha256:hash,tarballIntegrity:'sha512-'+'A'.repeat(86)+'==',fileInventorySha256:hash,evidenceBundleSha256:hash,evidenceArtifactId:123,ownerNpmLogin:'odenknight',gates:REQUIRED_GATES.map(name=>({name,status:'PASS',sourceCommit:commit,receiptSha256:hash,evidenceUrl:'https://github.com/Odenknight/GKOS-Engine/actions/runs/123',...(name==='soak-24h'?{durationSeconds:86400,unexplainedFailures:0}:{})}))}; +} +test('release approval rejects omitted/failed/duplicate gates, short soak, different commit or artifact', () => { + assert.equal(validateApproval(approval(),commit).sourceCommit,commit); + for (const mutate of [ + r=>r.gates.pop(), r=>r.gates.push(r.gates[0]), r=>r.gates[0].status='FAIL', + r=>r.gates[0].sourceCommit='c'.repeat(40), r=>r.gates.find(x=>x.name==='soak-24h').durationSeconds=86399, + r=>r.gates.find(x=>x.name==='soak-24h').unexplainedFailures=1, + r=>r.version='2.2.1', r=>r.ownerNpmLogin='other', r=>r.evidenceArtifactId=0, + ]) { const record=approval(); mutate(record); assert.throws(()=>validateApproval(record,commit)); } + assert.throws(()=>validateApproval(approval(),'c'.repeat(40))); + assert.throws(()=>validateApproval(null,commit)); + assert.throws(()=>verifyArtifact(approval(),Buffer.from('different'),{})); +}); +test('artifact verification binds both digests and exact bounded inventory', () => { + const bytes=Buffer.from('synthetic archive fixture'), files=[{path:'package.json',size:20,mode:420}]; + const record=approval(); + record.tarballSha256=createHash('sha256').update(bytes).digest('hex'); + record.tarballIntegrity='sha512-'+createHash('sha512').update(bytes).digest('base64'); + record.fileInventorySha256=createHash('sha256').update(JSON.stringify(files)).digest('hex'); + const report={name:'gkos-engine',version:'2.2.0',filename:'gkos-engine-2.2.0.tgz',size:bytes.length,unpackedSize:20,files}; + assert.equal(verifyArtifact(record,bytes,report).fileCount,1); + assert.throws(()=>verifyArtifact(record,bytes,{...report,files:[...files,...files]})); + for(const path of ['.npmrc','src/.env','../escape','private/key.pem','state.sqlite']) { + assert.throws(()=>verifyArtifact(record,bytes,{...report,files:[{path,size:20,mode:420}]})); + } +}); From 234560673d67047e31ad886b65df62a50f467009 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 10:41:19 -0400 Subject: [PATCH 05/10] Handle npm 12 pack reports and preserve current qualification limits --- .github/workflows/npm-release-2.2.yml | 5 +++-- .../runtime-qualification/v1/change-inventory.json | 14 +++++++------- docs/CURRENT_CAPABILITIES.md | 4 ++-- docs/RELEASE-STATUS.md | 12 ++++++++---- scripts/release-220-preflight.mjs | 14 +++++++++++--- test/release-220-preflight.test.mjs | 8 +++++++- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.github/workflows/npm-release-2.2.yml b/.github/workflows/npm-release-2.2.yml index df81b8b..228f2d6 100644 --- a/.github/workflows/npm-release-2.2.yml +++ b/.github/workflows/npm-release-2.2.yml @@ -13,7 +13,7 @@ jobs: publish: if: github.repository == 'Odenknight/GKOS-Engine' runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 120 environment: gkos-engine-release env: GKOS_220_APPROVAL_JSON: ${{ vars.GKOS_220_APPROVAL_JSON }} @@ -96,7 +96,8 @@ jobs: run: | set -euo pipefail npm view gkos-engine versions --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/versions-immediate.json" - node -e 'const fs=require("node:fs"); if(JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP+"/release-evidence/versions-immediate.json")).includes("2.2.0"))process.exit(1)' + npm view gkos-engine dist-tags --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/release-evidence/dist-tags-immediate.json" + node -e 'const fs=require("node:fs"),assert=require("node:assert/strict"),root=process.env.RUNNER_TEMP+"/release-evidence/"; assert.ok(!JSON.parse(fs.readFileSync(root+"versions-immediate.json")).includes("2.2.0")); assert.equal(JSON.parse(fs.readFileSync(root+"dist-tags-immediate.json")).latest,"2.0.1")' node scripts/release-220-preflight.mjs --artifact "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" "$RUNNER_TEMP/release-evidence/pack.json" npm publish "$RUNNER_TEMP/release-evidence/gkos-engine-2.2.0.tgz" --ignore-scripts --registry=https://registry.npmjs.org --tag=latest --access=public - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 137ee14..7a8c8d9 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -398,7 +398,7 @@ { "path": ".github/workflows/npm-release-2.2.yml", "before": null, - "after": "8005378fc1e466eda9f5b61c048179d455c934073be964be5c9960c1d33c0ccc", + "after": "89289faba34af4b3ae2f0c8b9a644f55f8fad15205ef733c3559ba158c5c319e", "rationale": "Add immutable exact-tag OIDC-only release workflow that refuses absent approval/evidence and mismatched source or tarball; no release triggered." }, { @@ -620,8 +620,8 @@ { "path": "docs/CURRENT_CAPABILITIES.md", "before": "154d01ab1328d6f7a8cc10974cde6d8651402357", - "after": "283ce2d15fc7cd02d5e07d7664a40aaab93156d8803dd04b196d703aac62d0fd", - "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." + "after": "78c3494cd54aa880949782cd6a76e932ce4db5b291a8e627ace67caa42799a90", + "rationale": "Describe implemented separate durable no-op audit while preserving unqualified scale/soak and filesystem limitations." }, { "path": "docs/DETERMINISTIC-MOC-ASSISTANCE.md", @@ -656,8 +656,8 @@ { "path": "docs/RELEASE-STATUS.md", "before": null, - "after": "c4be9892ebed0bb9db87c46f91f37a7bbff1767068af66e4122deafedba3c652", - "rationale": "Document current source capabilities, release status and exact merged verification evidence; no runtime or release activation." + "after": "d825e703221185c741951bd9b0f20b0d40e35f67a9eafdcf3eb0199994efda2a", + "rationale": "Record exact candidate focused evidence without claiming publication or completion of remaining mandatory gates." }, { "path": "docs/USEFULNESS-AUDIT-FOLLOWUP-20260908.md", @@ -800,7 +800,7 @@ { "path": "scripts/release-220-preflight.mjs", "before": null, - "after": "d11911dc9445686974dd73df407edb53a08d0286cbb59c3a1f0253d0745d60a2", + "after": "581f6adb15db65971d66eec236dbc537e22595c2ab357559252209a0585f4747", "rationale": "Fail closed on any missing mandatory exact-commit gate, incomplete soak, missing evidence binding, wrong tag or inspected tarball digest/inventory." }, { @@ -1112,7 +1112,7 @@ { "path": "test/release-220-preflight.test.mjs", "before": null, - "after": "b71c27236f755d6ccfeb087839e3336b77c3763efe92c42fc69d93ad1a767e0e", + "after": "bc2214c5c44b159128bdb9db1b5d225e622c1b11ae16129babb5ac5dfea88a17", "rationale": "Negative and positive preflight tests for mandatory gates, source identity, soak length, archive digests and package inventory." }, { diff --git a/docs/CURRENT_CAPABILITIES.md b/docs/CURRENT_CAPABILITIES.md index 71922ae..0e4fcd4 100644 --- a/docs/CURRENT_CAPABILITIES.md +++ b/docs/CURRENT_CAPABILITIES.md @@ -37,7 +37,7 @@ The executor uses a vault lease, scoped locks, durable intent, before-image arch Startup recovery and full reconciliation precede readiness. Overflow and missed events require reconciliation, not faith in watchers. Graph publication can be retried with the same effect ID; consumers must be idempotent. A stopped timer is not a durable clean-shutdown receipt. -The packaged desktop service does not wire this host to a source-write route. Kosmos must complete its own adapter/UI/credential integration. No dedicated durable no-op audit receipt is currently emitted by byte-identical host passes. End-to-end parsing/P95, 24-hour soak, physical power-loss and hostile filesystem-ancestor race protection remain unqualified. +The packaged desktop service does not wire this host to a source-write route. Kosmos must complete its own adapter/UI/credential integration. A dedicated durable no-op audit receipt is emitted by byte-identical host passes; its separate versioned artifact, file-sync/readback protocol and recovery limits are documented in [the no-change audit guide](MANAGED-MOC-NO-CHANGE-AUDIT.md). End-to-end parsing/P95, 24-hour soak, physical power-loss and hostile filesystem-ancestor race protection remain unqualified. ## Exact MCP tool inventory @@ -88,7 +88,7 @@ Current package lanes: Node 22 and 24 blocking, Node 26 informative; npm >=10. H ## Not yet delivered or not authorized by default - Production Kosmos write integration, per-agent credential/root lifecycle and create/update/append/archive MCP tools. -- Dedicated durable no-op host audit receipts; fully qualified scale/soak and native durability guarantees. +- Fully qualified scale/soak and comprehensive native durability guarantees beyond the bounded no-op audit recovery checks. - Enabled proposal ingress, agent approval/decision routes, automatic adoption, deletion or cross-root authority. - LAN/internet service binding, token-in-URL mode, automatic sensitivity lowering or confidence-selected lineage winners. - Automatic updater/signing/notarization, a published 2.2 artifact, Rust parity or new GKOS conformance. diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md index a79022e..98de329 100644 --- a/docs/RELEASE-STATUS.md +++ b/docs/RELEASE-STATUS.md @@ -1,6 +1,6 @@ # Source and release status -Checked September 6, 2026. This page separates implemented code from published +Checked September 8, 2026. This page separates implemented code from published artifacts and enabled product features. For the functional inventory, see [current capabilities](CURRENT_CAPABILITIES.md); for remaining work, see the [roadmap](../ROADMAP.md). @@ -22,9 +22,13 @@ and [historical/current runtime qualification](https://github.com/Odenknight/GKO These results belong to that exact revision. They do not certify later commits, a downstream Obsidian installation, physical power-loss safety, or a 24-hour soak. -Remaining qualification includes dedicated durable no-op audit receipts, -end-to-end latency and incremental parsing measurements, long-running soak, -native durability evidence, and exact-artifact consumer integration. Track +The release branch adds separately versioned 2.2 retrieval observation and +durable no-op audit receipts. Native Linux/Windows focused audit checks and +packed consumer smoke checks have passed on candidate +`458a23f3771ae7b303cf03956727d0b0a7eed98a`; these do not qualify later commits. +Remaining qualification includes end-to-end latency and incremental parsing +measurements, the full 24-hour soak, comprehensive native durability evidence, +and exact Kosmos consumer integration. Track [Engine #44](https://github.com/Odenknight/GKOS-Engine/issues/44). ## Choosing and publishing a version diff --git a/scripts/release-220-preflight.mjs b/scripts/release-220-preflight.mjs index 1e915ca..1ed0ed4 100644 --- a/scripts/release-220-preflight.mjs +++ b/scripts/release-220-preflight.mjs @@ -15,6 +15,15 @@ export const REQUIRED_GATES = Object.freeze([ ]); const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); const hex = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +export function parsePackReport(text) { + const value = JSON.parse(text); + // npm 12 keys pack reports by package name; older CLIs returned an array. + const reports = Array.isArray(value) ? value : Object.keys(value).length === 1 && Object.hasOwn(value, 'gkos-engine') ? [value['gkos-engine']] : []; + assert.equal(reports.length, 1); + assert.equal(reports[0]?.name, 'gkos-engine'); + assert.equal(reports[0]?.version, '2.2.0'); + return reports[0]; +} export function validateApproval(record, actualCommit) { assert.equal(record?.schema, 'gkos-engine-release-approval/2.2.0'); assert.equal(record.package, 'gkos-engine'); @@ -74,9 +83,8 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur git('merge-base','--is-ancestor',head,'origin/main'); if (process.argv[2] === '--artifact') { assert.equal(sha256(readFileSync(resolve(process.env.RUNNER_TEMP, 'release-evidence', 'qualified-evidence.zip'))), record.evidenceBundleSha256); - const reports = JSON.parse(readFileSync(process.argv[4], 'utf8')); - assert.equal(reports.length, 1); - console.log(JSON.stringify(verifyArtifact(record, readFileSync(process.argv[3]), reports[0]))); + const report = parsePackReport(readFileSync(process.argv[4], 'utf8')); + console.log(JSON.stringify(verifyArtifact(record, readFileSync(process.argv[3]), report))); } else { assert.equal(process.argv.length, 2); console.log(JSON.stringify({ status:'PASS', sourceCommit:head, approvalSha256:sha256(process.env.GKOS_220_APPROVAL_JSON) })); diff --git a/test/release-220-preflight.test.mjs b/test/release-220-preflight.test.mjs index 641687b..8c861f9 100644 --- a/test/release-220-preflight.test.mjs +++ b/test/release-220-preflight.test.mjs @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createHash } from 'node:crypto'; -import { REQUIRED_GATES, validateApproval, verifyArtifact } from '../scripts/release-220-preflight.mjs'; +import { REQUIRED_GATES, validateApproval, verifyArtifact, parsePackReport } from '../scripts/release-220-preflight.mjs'; const commit = 'a'.repeat(40), hash = 'b'.repeat(64); +test('pack report accepts npm 12 and legacy single-package reports and rejects ambiguity', () => { + const report = { name:'gkos-engine', version:'2.2.0' }; + assert.deepEqual(parsePackReport(JSON.stringify({'gkos-engine':report})), report); + assert.deepEqual(parsePackReport(JSON.stringify([report])), report); + for (const value of [null, {}, [], [report, report], {other:report}, {'gkos-engine':report, other:report}, {'gkos-engine':{...report,version:'2.2.1'}}]) assert.throws(()=>parsePackReport(JSON.stringify(value))); +}); function approval() { return { schema:'gkos-engine-release-approval/2.2.0',package:'gkos-engine',version:'2.2.0',tag:'v2.2.0',sourceCommit:commit,tarballSha256:hash,tarballIntegrity:'sha512-'+'A'.repeat(86)+'==',fileInventorySha256:hash,evidenceBundleSha256:hash,evidenceArtifactId:123,ownerNpmLogin:'odenknight',gates:REQUIRED_GATES.map(name=>({name,status:'PASS',sourceCommit:commit,receiptSha256:hash,evidenceUrl:'https://github.com/Odenknight/GKOS-Engine/actions/runs/123',...(name==='soak-24h'?{durationSeconds:86400,unexplainedFailures:0}:{})}))}; } From 6e4e937f5bf6b3b49809072e49beafee71c3fcd8 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 12:56:47 -0400 Subject: [PATCH 06/10] Avoid quadratic candidate scans during unchanged incremental indexing --- .../runtime-qualification/v1/change-inventory.json | 6 ++++++ src/incremental.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 7a8c8d9..46b5d07 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -851,6 +851,12 @@ "after": "75f2e6bbc3a58c04403e1a6d9d8c47ea6eb90712e4856b1638003f631be04fbf", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "src/incremental.ts", + "before": "b8e7c28ee99596e461123aca4597237277be758e", + "after": "b2b08ec67eddcf025e7b9a54484851902afdfe573451f764e411198fb456c71f", + "rationale": "Group canonical candidates once after rename/removal processing to eliminate repeated full-set scans for unchanged source submissions; preserve all identity, duplicate, reuse and validation checks." + }, { "path": "src/index.ts", "before": "b4550c73992d38f7fc3e52383efde0de276ae27d", diff --git a/src/incremental.ts b/src/incremental.ts index b8e7c28..e7e47f5 100644 --- a/src/incremental.ts +++ b/src/incremental.ts @@ -315,8 +315,20 @@ export class GkxIndex { group.push(file); changedByPath.set(path, group); } + // Snapshot path groups after renames/removals. Each normalized changed path + // is processed once, so its previous group stays valid throughout this loop. + // Avoid scanning the complete candidate set once per submitted source. + const previousByPath = new Map(); + if (changedByPath.size > 0) { + for (const record of this.candidateRecords.values()) { + if (!changedByPath.has(record.relativePath)) continue; + const previous = previousByPath.get(record.relativePath) ?? []; + previous.push(record); + previousByPath.set(record.relativePath, previous); + } + } for (const [path, group] of changedByPath) { - const previous = [...this.candidateRecords.values()].filter((record) => record.relativePath === path); + const previous = previousByPath.get(path) ?? []; const incomingDescriptors = group.map(canonicalCandidateSourceDescriptor).sort(); const previousDescriptors = previous.map(canonicalCandidateRecordDescriptor).sort(); // The complete canonical parser descriptor, including source times and From aa2e10d16983b6cbd4107464f376d87d3c546db3 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 14:48:57 -0400 Subject: [PATCH 07/10] Bound secure watcher scans to reduce shutdown retry latency --- .../v1/change-inventory.json | 22 ++++++++- ...DOWS-QUALIFICATION-REMEDIATION-20260908.md | 40 ++++++++++++++++ src/ingest/source-scan.ts | 47 ++++++++++++++----- test/watcher-large-restart.test.mjs | 5 +- test/watcher-source-scan.test.mjs | 41 ++++++++++++++++ 5 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 docs/WINDOWS-QUALIFICATION-REMEDIATION-20260908.md diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 46b5d07..157feac 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -671,6 +671,12 @@ "after": "e1a8881dbb3ad3390a041d9d566609b8fb95082493276532320e8f3b23f00270", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "docs/WINDOWS-QUALIFICATION-REMEDIATION-20260908.md", + "before": null, + "after": "35730828ad967521c1f2b0e70206fcab0cc3941b74360ddd62f0b8e231f6be41", + "rationale": "Record exact failed candidate evidence, measured secure scan bottleneck, bounded implementation repair and remaining exact-head qualification gates without relaxing limits." + }, { "path": "docs/adr/0001-additive-navigation-effects-plane.md", "before": null, @@ -863,6 +869,12 @@ "after": "76e958a89e81530f75a166a34a19b6b1fc63a827d806a2f6a5c79035900fae77", "rationale": "Reviewed Navigation Effects integration: additive, default-disabled implementation, contract, evidence, or qualification coverage; no activation or release authority." }, + { + "path": "src/ingest/source-scan.ts", + "before": "c36a98bc8e379177475b5bef7d0df1c95cf45313", + "after": "43a0cad6a92bde0fe7445dc707452fc50503aacf10dc6cac27456bcfb5f63c41", + "rationale": "Bound independent secure watcher file probes to four; retain serial public Phase3 scanning, every capability/TOCTOU check, both complete snapshots and deterministic ordering; drain work before descent or failure." + }, { "path": "src/navigation-effects/assistance.ts", "before": null, @@ -1214,8 +1226,8 @@ { "path": "test/watcher-large-restart.test.mjs", "before": "6645c5680b85689548f07963fe596e05e12e03ef", - "after": "29978a55b73ec828fd1da1900ab7009e0f006209c2a22a86aec447287e9a8c49", - "rationale": "CI repair: prove large watcher restart binds and reopens the runtime-selected concrete lexical backend." + "after": "e9d2e3b1549b3ee450b563c791c13f7b7194fb3a718ee0ee6f5e8c59c9b0d6d3", + "rationale": "Retain the unchanged large graph, hardlink refusal, same-parent shutdown retry and pointer assertions; emit bounded shutdown duration for CI diagnosis." }, { "path": "test/watcher-observation-qualification.test.mjs", @@ -1234,6 +1246,12 @@ "before": "064563e6a26da7e90e76591fe907463bc29f3a0c", "after": "bb99d4a0794ad37141a096aaaad62b2d74155f7cb33a9176a7ac94525ef11a18", "rationale": "Q-GUARD coverage closure: register the native Linux shutdown regression only where the Linux watcher and physical FTS5 capability exist." + }, + { + "path": "test/watcher-source-scan.test.mjs", + "before": "a4d4b5f55a18e28b2bd209d5278685b39dd8123c", + "after": "6b78d365c46b4ec85dfd885f50863052da780242b85ce9dda5d302a162a222a3", + "rationale": "Assert bounded concurrent opens across nested directories, exact stable scan evidence and no pending file work after refusal." } ] } diff --git a/docs/WINDOWS-QUALIFICATION-REMEDIATION-20260908.md b/docs/WINDOWS-QUALIFICATION-REMEDIATION-20260908.md new file mode 100644 index 0000000..dccb334 --- /dev/null +++ b/docs/WINDOWS-QUALIFICATION-REMEDIATION-20260908.md @@ -0,0 +1,40 @@ +# Windows qualification remediation, September 8, 2026 + +Candidate 6e4e937f5bf6b3b49809072e49beafee71c3fcd8 remains unqualified. +Runtime workflow 34254132747 attempt 1 exceeded the mandatory Windows Node 22 +30-minute test-command limit. Attempt 2 completed 1,090 tests with one failure: +the large restart fixture raised GKX_WATCHER_SHUTDOWN_UNSAFE. Its topology was +1,552,042 bytes and graph 30,764,374 bytes. The informational Node 26 retry passed. +Neither another successful lane nor a synthetic PR merge qualifies this source. + +An isolated diagnostic retaining the large-restart assertions on native Windows +Node 22.23.2 measured the shutdown retry scan at 6,178.9 ms, followed by about +615 ms of failure artifact/journal persistence. Its secure startup scan took +15,633.4 ms for two snapshots. Local completion alone did not reproduce the +hosted deadline failure; these measurements identify the serial scan as the +largest measured contributor, not proof of the hosted runner's exact timing. + +The shared scanner now supports internally bounded file probes. The watcher +uses four; the public Phase3 scan remains serial. Directory descent drains +pending work, so nesting cannot multiply the bound. All probes settle before +an error returns. Results retain canonical ordering. All original alias, +containment, handle/path identity, byte-count, UTF-8, root recheck, and two-snapshot +namespace comparisons remain. No cached capability or partial scan is accepted. + +The 10-second shutdown deadline, fresh same-parent failure reconciliation, +durable failure tail, pointer convergence, observation latency thresholds and +30-minute qualification command limit are unchanged. The large-restart test +now emits a bounded phase duration even if shutdown rejects. No private paths +or source contents are added to the diagnostic. + +Type checking and build passed. Initial focused native Windows Node 22.23.2 +execution passed six tests: the original large restart plus five secure-scan +checks, including new concurrency/evidence and drain-before-refusal regressions. +A second native Windows Node 22.23.2 run passed all 47 shutdown, ingestion, +path-security and qualification regressions in 212.7 seconds. The complete +failed-reconciliation shutdown took 3,040.0 ms, with its deadline unchanged. +The deterministic barrier version of the five secure-scan tests also passed. +Full mandatory Linux/Windows Node 22/24 qualification must pass on the resulting +commit. The initial timeout is not considered resolved until that matrix passes. +The 24-hour soak and remaining release gates remain mandatory; no release or +publication is authorized by these focused results alone. diff --git a/src/ingest/source-scan.ts b/src/ingest/source-scan.ts index c36a98b..07a077d 100644 --- a/src/ingest/source-scan.ts +++ b/src/ingest/source-scan.ts @@ -278,6 +278,10 @@ function excluded(path: string, extra: ReadonlySet): boolean { /** Single Phase-3 scanner shared by the established CLI and the watcher. */ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOptions = {}): Promise { + return scanCorpus(dir, options, 1); +} + +async function scanCorpus(dir: string, options: Phase3CorpusScanOptions, fileConcurrency: number): Promise { const files: SourceFile[] = []; const attachments: string[] = []; const folders: string[] = []; @@ -433,9 +437,9 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt async function walk(absolute: string, relativePath: string): Promise { const entries = await readdir(absolute, { withFileTypes: true }); entries.sort((left, right) => codeUnitCompare(left.name, right.name)); - for (const entry of entries) { + const inspectEntry = async (entry: (typeof entries)[number]): Promise => { const childRel = portablePath(relativePath ? `${relativePath}/${entry.name}` : entry.name); - if (excluded(childRel, extra)) continue; + if (excluded(childRel, extra)) return; const childAbs = join(absolute, entry.name); await options.on_before_child_lstat?.({ relative_path: childRel, absolute_path: childAbs }); let linkState: FileState; @@ -446,7 +450,7 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt rejection(childRel, null, ["ENOENT", "ESTALE"].includes(candidate?.code ?? "") ? "SOURCE_SNAPSHOT_CHANGED_DURING_SCAN" : "SOURCE_READ_FAILED"); - continue; + return; } throw error; } @@ -462,11 +466,11 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt ...(linkState.isSymbolicLink() ? ["SOURCE_FILESYSTEM_ALIAS_REJECTED"] : []), "SOURCE_SNAPSHOT_CHANGED_DURING_SCAN", ]); - continue; + return; } if (linkState.isSymbolicLink()) { rejection(childRel, linkState, "SOURCE_FILESYSTEM_ALIAS_REJECTED"); - continue; + return; } if (linkState.isDirectory()) { let canonicalDirectory: string; @@ -475,7 +479,7 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt if (!canonicalPathContains(actualRoot, canonicalDirectory)) throw new Error("GKX_SCAN_SOURCE_PATH_ESCAPE"); } catch { rejection(childRel, linkState, "SOURCE_FILESYSTEM_ALIAS_REJECTED"); - continue; + return; } folders.push(childRel); namespaceRow(childRel, "folder", linkState); @@ -484,7 +488,7 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt if (preciseRow !== null) losslessNamespaceRows.push(preciseRow); namespaceRow(childRel, "note", linkState); const inspected = await inspectPlainContainedFile(childAbs, childRel, true); - if (!inspected) continue; + if (!inspected) return; const file: SourceFile = { relativePath: childRel, name: entry.name, @@ -514,7 +518,28 @@ export async function scanPhase3Corpus(dir: string, options: Phase3CorpusScanOpt if (inspected) attachments.push(childRel); } if (namespaceRows.length > MAX_SOURCES) fail("WATCHER_SOURCE_CAPABILITY_UNSTABLE"); + }; + // Preserve every per-file capability check while overlapping independent + // filesystem waits. Drain before descending so the bound applies to the + // whole scan, including nested directories. Drain failures too: no file + // operation may outlive a rejected scan or race the caller's recovery. + let pending: Promise[] = []; + const drain = async (): Promise => { + const results = await Promise.allSettled(pending); + pending = []; + const rejected = results.find((result) => result.status === "rejected"); + if (rejected?.status === "rejected") throw rejected.reason; + }; + for (const entry of entries) { + if (entry.isDirectory()) { + await drain(); + await inspectEntry(entry); + } else { + pending.push(inspectEntry(entry)); + if (pending.length === fileConcurrency) await drain(); + } } + await drain(); } await walk(actualRoot, ""); @@ -602,7 +627,7 @@ function watcherIdentity( export async function secureWatcherSourceScan(vaultRoot: string, options: WatcherSourceScanOptions = {}): Promise { const exclusions = [...new Set([WATCHER_ARCHIVE_EXCLUSION, ...(options.extra_exclusions ?? [])])]; - const first = await scanPhase3Corpus(vaultRoot, { + const first = await scanCorpus(vaultRoot, { ingest: true, extra_exclusions: exclusions, capture_lossless_namespace_evidence: true, @@ -610,7 +635,7 @@ export async function secureWatcherSourceScan(vaultRoot: string, options: Watche ? undefined : async ({ relative_path }) => options.on_after_file_open?.(relative_path), on_before_root_recheck: options.on_after_first_snapshot, - }); + }, 4); const rejections = first[PHASE3_SCAN_REJECTIONS] ?? []; // Only the stable, pre-NoteRecord size rejection is eligible for the // deterministic N-1 validation path. Invalid UTF-8, read instability, @@ -618,9 +643,9 @@ export async function secureWatcherSourceScan(vaultRoot: string, options: Watche if (rejections.some((row) => row.reason_codes.some((reason) => reason !== "SOURCE_SIZE_LIMIT_EXCEEDED"))) { fail("WATCHER_SOURCE_CAPABILITY_UNSTABLE"); } - const second = await scanPhase3Corpus(vaultRoot, { + const second = await scanCorpus(vaultRoot, { ingest: true, extra_exclusions: exclusions, capture_lossless_namespace_evidence: true, - }); + }, 4); const firstRows = first[PHASE3_NAMESPACE_EVIDENCE] ?? []; const secondRows = second[PHASE3_NAMESPACE_EVIDENCE] ?? []; const firstLosslessRows = first[PHASE3_LOSSLESS_NAMESPACE_EVIDENCE] ?? []; diff --git a/test/watcher-large-restart.test.mjs b/test/watcher-large-restart.test.mjs index 5ae58bf..35ed010 100644 --- a/test/watcher-large-restart.test.mjs +++ b/test/watcher-large-restart.test.mjs @@ -28,7 +28,10 @@ test('restart and unchanged retry reopen coherent topology/graph larger than 1 M const unstable=join(vault,'unstable.md'),alias=join(vault,'unstable-alias.md'); writeFileSync(unstable,'# unstable\n',{mode:0o600});linkSync(unstable,alias); await assert.rejects(()=>host.reconcile('event'),/WATCHER_SOURCE_CAPABILITY_UNSTABLE/); - await host.shutdown();await host.closed;host=null; + const shutdownStarted = performance.now(); + try { await host.shutdown(); await host.closed; } + finally { t.diagnostic(JSON.stringify({phase:'failed-reconciliation-shutdown',elapsed_ms:performance.now()-shutdownStarted})); } + host=null; unlinkSync(alias);unlinkSync(unstable); host=await startWatcherHost(options);assert.equal(host.status().document_count,count); assert.deepEqual(readFileSync(join(watcher,'watcher-active.json')),before); diff --git a/test/watcher-source-scan.test.mjs b/test/watcher-source-scan.test.mjs index a4d4b5f..c877402 100644 --- a/test/watcher-source-scan.test.mjs +++ b/test/watcher-source-scan.test.mjs @@ -98,3 +98,44 @@ test("watch hints are advisory and unsafe names force unscoped reconciliation", assert.equal(normalizeWatcherHint(""), null); assert.equal(normalizeWatcherHint("bad\ud800.md"), null); }); + + +test("secure scans bound concurrent opens across nested directories and preserve ordered evidence", { timeout: 10000 }, async (t) => { + const root = vault(t); + for (let i = 0; i < 12; i++) put(root, `note-${String(i).padStart(2, "0")}.md`, `# Note ${i}\n`); + for (let i = 0; i < 8; i++) put(root, `nested/note-${i}.md`, `# Nested ${i}\n`); + const expected = await secureWatcherSourceScan(root); + let active = 0, peak = 0, completed = 0, arrived = 0; + let releaseFirst; + const firstBatch = new Promise(resolve => { releaseFirst = resolve; }); + const actual = await secureWatcherSourceScan(root, { + async on_after_file_open(path) { + active++; peak = Math.max(peak, active); + if (++arrived === 4) releaseFirst(); + await firstBatch; + await new Promise(resolve => setTimeout(resolve, path.endsWith("0.md") ? 8 : 1)); + active--; completed++; + }, + }); + assert.equal(completed, 20); + assert.equal(active, 0); + assert.equal(peak, 4, "the global scan bound is four even across directory descent"); + assert.deepEqual(actual, expected, "completion order cannot change files, identities or namespace digest"); +}); + +test("a failed concurrent secure scan drains file work before returning refusal", async (t) => { + const root = vault(t); + for (let i = 0; i < 8; i++) put(root, `note-${i}.md`, `# Note ${i}\n`); + let active = 0, completed = 0; + await assert.rejects(secureWatcherSourceScan(root, { + async on_after_file_open(path) { + active++; + try { + await new Promise(resolve => setTimeout(resolve, path === "note-0.md" ? 1 : 8)); + if (path === "note-0.md") throw new Error("fixture read failure"); + } finally { active--; completed++; } + }, + }), /WATCHER_SOURCE_CAPABILITY_UNSTABLE/u); + assert.equal(active, 0, "no pending open hook may race recovery after refusal"); + assert.equal(completed, 8); +}); From ea055319a50d93e0f1b181e478731b437a152f17 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 16:07:54 -0400 Subject: [PATCH 08/10] Keep incremental graph link identities independent of edit order --- .../v1/change-inventory.json | 16 ++++++++-- .../GRAPH-CONVERGENCE-REMEDIATION-20260908.md | 31 +++++++++++++++++++ src/incremental.ts | 8 ++++- test/determinism.test.mjs | 22 +++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 docs/GRAPH-CONVERGENCE-REMEDIATION-20260908.md diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 157feac..41f94b1 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -629,6 +629,12 @@ "after": "a34c724c3db22d9eafd13739b38dad0f7fdbb2ce3d6624ace11596a87a6b7dc9", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "docs/GRAPH-CONVERGENCE-REMEDIATION-20260908.md", + "before": null, + "after": "38e7a280d18b34b7b005892b755126fcfbd4baf083eabb260b46fac51f66cd92", + "rationale": "Record the soak-pilot ordering defect, independently reproduced link-ID drift, canonical assembly repair and pending new-candidate qualification; preserve failed evidence." + }, { "path": "docs/MANAGED-MOC-NO-CHANGE-AUDIT.md", "before": null, @@ -860,8 +866,8 @@ { "path": "src/incremental.ts", "before": "b8e7c28ee99596e461123aca4597237277be758e", - "after": "b2b08ec67eddcf025e7b9a54484851902afdfe573451f764e411198fb456c71f", - "rationale": "Group canonical candidates once after rename/removal processing to eliminate repeated full-set scans for unchanged source submissions; preserve all identity, duplicate, reuse and validation checks." + "after": "5765ac4fac40b2d32e3cbf81f97bddfd5aa041ccca265b7d8e58d903b0c115b4", + "rationale": "Group candidates once to avoid quadratic unchanged scans; sort representative source paths before assembly so incremental history cannot alter link order or generated IDs; preserve all candidate, reuse and validation checks." }, { "path": "src/index.ts", @@ -1055,6 +1061,12 @@ "after": "8ccdfb38565d16b38bb20f2d625cb1ce263d631ff46519a7473f3fd4e18d81f2", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "test/determinism.test.mjs", + "before": "d29098024595fc7d53fccb17c69e912c0d1a7457", + "after": "9f1bc0062d8846ba32bb73683cc45db8bdad6ca691eae90bf15fe3c950e1a8e7", + "rationale": "Regress edited and permuted source traversal: exact full ordered node/link equality and link identities must converge with a clean rebuild while parsing only changed content." + }, { "path": "test/documentation-capabilities.test.mjs", "before": null, diff --git a/docs/GRAPH-CONVERGENCE-REMEDIATION-20260908.md b/docs/GRAPH-CONVERGENCE-REMEDIATION-20260908.md new file mode 100644 index 0000000..6e687ed --- /dev/null +++ b/docs/GRAPH-CONVERGENCE-REMEDIATION-20260908.md @@ -0,0 +1,31 @@ +# Graph convergence remediation, September 8, 2026 + +Candidate aa2e10d16983b6cbd4107464f376d87d3c546db3 passed its exact native +Linux/Windows runtime matrix and full local Windows release-command sequence. +A subsequent bounded soak pilot nevertheless found a release-blocking graph +convergence defect. No 24-hour soak, release tag or publication had occurred. + +Replacing a canonical source record removes and reinserts its Map entry. Graph +assembly consumed this insertion order. A body-only edit to the first file +therefore moved its containment link to the end of the output array. Parsed +links also incorporate traversal position in their IDs. Independent regression +with wikilinks confirmed that IDs, not only array ordering, could differ from +a clean rebuild of the same final input. + +GkxIndex now sorts representative records by normalized source-path keys before +assembly, using locale-independent code-unit comparison. The existing sorted +candidate ledger is unchanged. This preserves graph content, all validation and +identity checks, and exactly one parse for one changed file. It makes traversal +independent of incremental edit history and input record order. + +The new regression failed before the repair and passes afterward. It edits +ASCII and non-ASCII paths repeatedly, rebuilds from reversed final inputs, +compares complete ordered node/link arrays including IDs, and verifies zero +parses for an unchanged retry. Type checking, build and 69 focused graph, +lineage, candidate-ledger, incremental and determinism tests pass. + +The pilot comparison was not weakened or replaced with sorted result sets. +Failed pilot receipts and the original graph differences are retained outside +the repository in the release evidence bundle. The resulting new source commit +requires new exact runtime, observation, artifact and consumer qualification and +a successful real 24-hour soak. Prior candidate passes do not qualify it. diff --git a/src/incremental.ts b/src/incremental.ts index e7e47f5..fc9d484 100644 --- a/src/incremental.ts +++ b/src/incremental.ts @@ -455,7 +455,13 @@ export class GkxIndex { const candidates = [...this.candidateRecords.entries()] .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) .map(([, record]) => record); - const graph = assembleGraphWithCanonicalCandidates([...this.records.values()], candidates, this.folders); + // Replacing a canonical record changes Map insertion order. Assembly + // derives link order and some link IDs from traversal order, so bind it + // to source paths rather than the history of incremental edits. + const records = [...this.records.entries()] + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([, record]) => record); + const graph = assembleGraphWithCanonicalCandidates(records, candidates, this.folders); graph.diagnostics.attachments = this.attachments.length; return graph; } diff --git a/test/determinism.test.mjs b/test/determinism.test.mjs index d290980..e3acaf4 100644 --- a/test/determinism.test.mjs +++ b/test/determinism.test.mjs @@ -64,3 +64,25 @@ test("two consecutive full builds of the same fixture are byte-identical", () => const gb = b.setFiles(fixture(), []).graph; assert.equal(JSON.stringify(stripVolatile(ga)), JSON.stringify(stripVolatile(gb))); }); + + +test("incremental edits preserve canonical link order and identities and converge with clean rebuild", () => { + const files = [N("z.md", "# Z\n[[a]]"), N("a.md", "# A\n[[z]]"), N("ä.md", "# Umlaut\n[[a]]")]; + const index = new GkxIndex(); + index.setFiles(files, []); + for (const path of ["a.md", "z.md", "ä.md", "a.md"]) { + const position = files.findIndex(file => file.relativePath === path); + files[position] = { ...files[position], content: files[position].content + "\nChanged body." }; + const before = index.parseCount; + index.applyChanges({ changed: [files[position]] }); + assert.equal(index.parseCount - before, 1); + const rebuilt = new GkxIndex(); + rebuilt.setFiles([...files].reverse(), []); + assert.deepEqual(index.graph.nodes, rebuilt.graph.nodes); + assert.deepEqual(index.graph.links, rebuilt.graph.links, "compare complete ordered links, including generated IDs"); + const unchanged = index.parseCount; + index.applyChanges({ changed: files }); + assert.equal(index.parseCount, unchanged); + assert.deepEqual(index.graph.links, rebuilt.graph.links); + } +}); From f7d80e6fce1b905a42c54423ef8ec4b7fc18fc22 Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 20:11:54 -0400 Subject: [PATCH 09/10] Bound admitted native retrieval source reads without changing custom callbacks --- .../v1/change-inventory.json | 22 ++++++- docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md | 46 ++++++++++++++ src/retrieval/coordinator.ts | 29 +++++++-- src/retrieval/native-read.ts | 20 +++++++ test/retrieval-native-read.test.mjs | 60 +++++++++++++++++++ 5 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md create mode 100644 src/retrieval/native-read.ts create mode 100644 test/retrieval-native-read.test.mjs diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 41f94b1..05d713f 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -641,6 +641,12 @@ "after": "42b2ec2c53ad2866c3a78e524dfdeac54b5e01b1e633d8f21b16c909ee43ac2f", "rationale": "Specify separate no-change audit artifact and truthful file-sync/recovery guarantees and remaining durability gates." }, + { + "path": "docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md", + "before": null, + "after": "fd5a04be1c5b9c8bf153aa8a09f6c82b95a763992a38bf8775955d4d0596fa13", + "rationale": "Record bounded native-read diagnostics and isolated repair limits without dismissing the old latency failure or claiming new candidate qualification." + }, { "path": "docs/NAVIGATION-EFFECTS-CONTRACT.md", "before": null, @@ -980,8 +986,8 @@ { "path": "src/retrieval/coordinator.ts", "before": "f7fe89a328c61bebfbfee87eb3d4bb3efdf89111", - "after": "0016afb181d572af42996a731de081683369ba572374a6386d8a5b5702c15cf7", - "rationale": "Q-GUARD Windows coverage closure: reopen private evaluation generations through the evaluation-only long-path SQLite authority without changing ordinary coordinator stores." + "after": "58d0a15f3599a3bb4ae0d282431f39e3a4acf705ad6fd84736f5302205d4dd2c", + "rationale": "Bound only Engine-native, unique-path eligible source reads to four after policy/filter/time admission; preserve custom/repeated-path serial semantics, every freshness check and deterministic result processing." }, { "path": "src/retrieval/evaluation-executor.ts", @@ -1019,6 +1025,12 @@ "after": "0b1a5e1acc36dfb8c6ba213736d93c3d03abe87fc7ec52623cebd61b6c9a4ab0", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "src/retrieval/native-read.ts", + "before": null, + "after": "bf43601948b0ab427f2ede63da89723e1994b1163d47e36417653f77fc636e47", + "rationale": "Internal four-worker ordered read primitive; retain failed-read refusal values and drain all admitted work before returning; not a public API." + }, { "path": "src/retrieval/sqlite-store.ts", "before": "b589fd9670548ea457a3c7f733b6017809c750fd", @@ -1163,6 +1175,12 @@ "after": "7dc4248c03f4a27f2799f020b20172b6e7cfa68cb3e6eb796fa18f01949216f9", "rationale": "Engine 2.2 MOC implementation: reviewed host/recovery, assistance, tests, version, docs and development dependency repair; frozen evidence unchanged." }, + { + "path": "test/retrieval-native-read.test.mjs", + "before": null, + "after": "965011839bde0c0bf9025cb7cfc98d07f4915898e6bc3d37f007826558b3b9f9", + "rationale": "Test exact four-read bound, ordered completion, failure draining, duplicate helper refusal, serial custom callbacks and native/custom stale-source result equality." + }, { "path": "test/retrieval-observation-2.2.test.mjs", "before": null, diff --git a/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md b/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md new file mode 100644 index 0000000..4bbc0ab --- /dev/null +++ b/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md @@ -0,0 +1,46 @@ +# Bounded native retrieval reads + +This isolated repair is based on ea05531. It is not yet the qualified release +candidate. The live ea05531 Linux soak and its installed artifact are unchanged. + +The Windows corrected soak pilot failed p95534.4491ms against the unchanged500ms +limit. Subsequent diagnostics did not reproduce that tail, so its exact cause +remains unresolved. They measured128 serial secure filesystem reads per query. +A static all-public fixture experiment, with all reads inside the query timer, +measured serial p95189–201ms versus94ms with four concurrent reads and identical +full hit digests across200queries. The implemented native path independently +measured94–97ms versus182–196ms for its serial fallback across200more queries. +These observations identify avoidable serialized read cost, not proof that the +old failing run was invalid or that every Windows environment meets the limit. + +Only readers produced by Engine's own vaultSourceReader factory in the same +module instance are eligible. A private WeakSet marks those functions; caller +callbacks and cross-bundle wrappers retain serial invocation. Repeated source +paths also retain the original serial path and retry/cache semantics. + +Source/chunk policy, filters and temporal eligibility are evaluated before any +batch starts. At most four already-admitted source reads are active. Every +existing canonical-path, containment, symlink, file-kind and hard-link check in +vaultSourceReader remains. Results retain input order; failed reads remain +stale-source refusals. All reads drain before digest, byte-span and line checks, +ranking and provider calls proceed. No vector/index identities, query limit, +latency threshold, public exports or frozen qualification files change. + +New deterministic tests cover the four-read bound, out-of-order completion, +failure draining, duplicate-path refusal in the internal helper, native/custom +result equality, serial custom callbacks and stale-source behavior. The broader +retrieval, temporal, authorized-view, provenance, Windows path-security and +service retrieval suite passes87tests on each of Windows Node22 and Node24. Exact results are +recorded in the external evidence ledger. Typecheck/build pass. +The initial new integration fixture used an invalid authored UID and was corrected +to a canonical UUID; its failed test log is preserved separately. + +Evidence: secure-read-concurrency-ea05531-1/receipt.json and +secure-read-implementation-ea05531-1/receipt.json in the external release evidence +directory. The latter records exact changed-source hashes, since it executed +before this commit existed. native-read-regressions*.log retains test results. +The experiment is all-public and unfiltered; broader policy behavior is covered +by the tests and must remain part of review. No qualification pass is inferred +for a newly packed artifact. Adoption requires exact-commit native matrix, +observation, package/consumer checks and new24-hour soak evidence. Preserve every +old failure and old-source pass with its original binding. \ No newline at end of file diff --git a/src/retrieval/coordinator.ts b/src/retrieval/coordinator.ts index 76a1072..5bb0e8f 100644 --- a/src/retrieval/coordinator.ts +++ b/src/retrieval/coordinator.ts @@ -1,3 +1,4 @@ +import { readNativeSourcesBounded } from "./native-read"; import { lstat, readFile, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { isValidRetrievalSourcePath, retrievalLineCoordinates } from "./chunker"; @@ -712,6 +713,8 @@ export async function indexGkxRetrievalGeneration( return indexCandidateGeneration(input, vectorProvider); } +const nativeVaultSourceReaders = new WeakSet(); + export function vaultSourceReader(vaultRoot: string): (sourcePath: string) => Promise { const requestedRoot = resolve(vaultRoot); const rootPromise = (async () => { @@ -720,7 +723,7 @@ export function vaultSourceReader(vaultRoot: string): (sourcePath: string) => Pr if (!rootState.isDirectory() || rootState.isSymbolicLink()) throw new Error("SOURCE_ROOT_ALIAS_REJECTED"); return actualRoot; })(); - return async (sourcePath) => { + const reader = async (sourcePath: string): Promise => { if (!isValidRetrievalSourcePath(sourcePath)) throw new Error("SOURCE_PATH_INVALID"); const root = await rootPromise; const requestedPath = resolve(root, sourcePath); @@ -732,6 +735,8 @@ export function vaultSourceReader(vaultRoot: string): (sourcePath: string) => Pr if ((await stat(actual)).nlink > 1) throw new Error("SOURCE_HARDLINK_REJECTED"); return readFile(actual); }; + nativeVaultSourceReaders.add(reader); + return reader; } export class RetrievalCoordinator { @@ -905,12 +910,28 @@ export class RetrievalCoordinator { const sourceBytes = new Map(); const eligible: RetrievalChunk[] = []; let staleCitation = false; - for (const group of new Map(policyEligible.map((chunk) => [chunk.source_id, chunksBySource.get(chunk.source_id)!])).values()) { + const sourceGroups = [...new Map(policyEligible.map((chunk) => [chunk.source_id, chunksBySource.get(chunk.source_id)!])).values()]; + const sourcePaths = sourceGroups.map((group) => group[0].source_path); + // Only Engine-created native readers can run concurrently. Arbitrary caller + // callbacks and repeated-path retry semantics keep the existing serial path. + // Admission above has already applied source/chunk policy and temporal filters. + const prefetched = nativeVaultSourceReaders.has(this.#options.source_reader) + && new Set(sourcePaths).size === sourcePaths.length + ? await readNativeSourcesBounded(sourcePaths, this.#options.source_reader) + : null; + for (const group of sourceGroups) { const first = group[0]; let bytes = sourceBytes.get(first.source_path); if (!bytes) { - try { bytes = await this.#options.source_reader(first.source_path); sourceBytes.set(first.source_path, bytes); } - catch { staleCitation = true; continue; } + if (prefetched !== null) { + const value = prefetched.get(first.source_path); + if (value === null || value === undefined) { staleCitation = true; continue; } + bytes = value; + sourceBytes.set(first.source_path, bytes); + } else { + try { bytes = await this.#options.source_reader(first.source_path); sourceBytes.set(first.source_path, bytes); } + catch { staleCitation = true; continue; } + } } if (retrievalSha256(bytes) !== first.source_digest || group.some((chunk) => { if (Buffer.from(bytes!).subarray(chunk.start_byte, chunk.end_byte).toString("utf8") !== chunk.text) return true; diff --git a/src/retrieval/native-read.ts b/src/retrieval/native-read.ts new file mode 100644 index 0000000..d801bbc --- /dev/null +++ b/src/retrieval/native-read.ts @@ -0,0 +1,20 @@ +/** Internal bounded native-read primitive. Call only after policy admission. + * Failed reads remain stale-source refusals, and all admitted work is drained. + */ +export async function readNativeSourcesBounded( + paths: readonly string[], + read: (path: string) => Uint8Array | Promise, +): Promise> { + if (new Set(paths).size !== paths.length) throw new Error("RETRIEVAL_NATIVE_READ_DUPLICATE_PATH"); + const results: Array = new Array(paths.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(4, paths.length) }, async () => { + for (;;) { + const index = cursor++; + if (index >= paths.length) return; + try { results[index] = await read(paths[index]); } + catch { results[index] = null; } + } + })); + return new Map(paths.map((path, index) => [path, results[index]])); +} \ No newline at end of file diff --git a/test/retrieval-native-read.test.mjs b/test/retrieval-native-read.test.mjs new file mode 100644 index 0000000..ddf63e3 --- /dev/null +++ b/test/retrieval-native-read.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { transformSync } from 'esbuild'; +import { buildRetrievalGeneration, chunkMarkdown, RetrievalCoordinator, retrievalCanonicalDigest, vaultSourceReader } from '../dist/retrieval.mjs'; +const code = transformSync(readFileSync(new URL('../src/retrieval/native-read.ts', import.meta.url), 'utf8'), { loader: 'ts', format: 'esm' }).code; +const { readNativeSourcesBounded } = await import('data:text/javascript;base64,' + Buffer.from(code).toString('base64')); + +test('native batch starts at most four reads and preserves input order despite out-of-order completion', async () => { + const gates = new Map(); const starts = []; let active = 0, maximum = 0; + const pending = readNativeSourcesBounded(['a', 'b', 'c', 'd', 'e', 'f'], async p => { + starts.push(p); active++; maximum = Math.max(maximum, active); + try { await new Promise(r => gates.set(p, r)); return Buffer.from(p); } finally { active--; } + }); + assert.deepEqual(starts, ['a', 'b', 'c', 'd']); + gates.get('d')(); await new Promise(setImmediate); + assert.deepEqual(starts, ['a', 'b', 'c', 'd', 'e']); + gates.get('b')(); await new Promise(setImmediate); + assert.deepEqual(starts, ['a', 'b', 'c', 'd', 'e', 'f']); + for (const p of ['f', 'e', 'c', 'a']) gates.get(p)(); + const result = await pending; + assert.equal(maximum, 4); assert.equal(active, 0); + assert.deepEqual([...result].map(([p, b]) => [p, b.toString()]), ['a','b','c','d','e','f'].map(p => [p,p])); +}); +test('failed native reads remain refusal values and outstanding work drains before completion', async () => { + let finish; let completed = false; + const pending = readNativeSourcesBounded(['bad', 'slow'], async p => { + if (p === 'bad') throw Error('native read failure'); + await new Promise(r => finish = r); return Buffer.from('verified'); + }).then(r => { completed = true; return r; }); + await new Promise(setImmediate); assert.equal(completed, false); + finish(); const result = await pending; + assert.equal(result.get('bad'), null); assert.equal(result.get('slow').toString(), 'verified'); + assert.deepEqual([...await readNativeSourcesBounded([], () => { throw Error('unexpected'); })], []); + await assert.rejects(readNativeSourcesBounded(['same', 'same'], () => { throw Error('must not read'); }), { message: 'RETRIEVAL_NATIVE_READ_DUPLICATE_PATH' }); +}); +test('native and custom readers preserve exact search results; custom callbacks remain serial', async t => { + const root = mkdtempSync(join(tmpdir(), 'gkos-native-read-')); + let custom, native; + t.after(() => { custom?.close(); native?.close(); assert.ok(resolve(root).startsWith(resolve(tmpdir()))); rmSync(root, { recursive: true, force: true }); }); + const chunks = []; + for (let i = 0; i < 6; i++) { + const text = '# Note '+i+'\nreaderneedle'+i+' public evidence.\n'; + writeFileSync(join(root, 'n'+i+'.md'), text); + chunks.push(...chunkMarkdown({ source_id: '550e8400-e29b-41d4-a716-'+String(i).padStart(12,'0'), source_path: 'n'+i+'.md', text, metadata: { sensitivity: 'public' } })); + } + const generation = buildRetrievalGeneration({ state_directory: join(root,'index'), vault_id:'native-read', source_snapshot_digest:retrievalCanonicalDigest(chunks), configuration_digest:retrievalCanonicalDigest({}), policy_digest:retrievalCanonicalDigest({}), chunks, lexical_backend:'sqlite_fts5' }); + let active=0, maximum=0, calls=0; const reader=vaultSourceReader(root); + custom = new RetrievalCoordinator(generation.database_path,{ discoverability_policy:()=> 'allow', source_reader:async p=>{active++;calls++;maximum=Math.max(maximum,active);try{await new Promise(setImmediate);return await reader(p);}finally{active--;}} }); + native = new RetrievalCoordinator(generation.database_path,{ discoverability_policy:()=> 'allow', source_reader:vaultSourceReader(root) }); + + const request={query:'readerneedle0',limit:1}; + const expected=await custom.search(request); const actual=await native.search(request); + assert.equal(expected.hits.length,1);assert.deepEqual(actual,expected);assert.equal(calls,6);assert.equal(maximum,1);assert.equal(active,0); + // A stale authorized file must not become an eligible citation on either path. + writeFileSync(join(root,'n0.md'),'changed source'); + assert.deepEqual(await native.search(request),await custom.search(request)); +}); \ No newline at end of file From bc1e6c35fa25c75a1e0dffc11771e85b61bcc70f Mon Sep 17 00:00:00 2001 From: OdenKnight Date: Tue, 8 Sep 2026 21:10:19 -0400 Subject: [PATCH 10/10] test: make packed MCP qualification setup cancellable --- .../v1/change-inventory.json | 6 +- docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md | 22 ++++- test/service-stdio-package.test.mjs | 98 ++++++++++++++----- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/contracts/runtime-qualification/v1/change-inventory.json b/contracts/runtime-qualification/v1/change-inventory.json index 05d713f..2389822 100644 --- a/contracts/runtime-qualification/v1/change-inventory.json +++ b/contracts/runtime-qualification/v1/change-inventory.json @@ -644,7 +644,7 @@ { "path": "docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md", "before": null, - "after": "fd5a04be1c5b9c8bf153aa8a09f6c82b95a763992a38bf8775955d4d0596fa13", + "after": "83523aee6d362b5f65a49aaf4648c16de21c57c01b96437a20fee0b1a4e53194", "rationale": "Record bounded native-read diagnostics and isolated repair limits without dismissing the old latency failure or claiming new candidate qualification." }, { @@ -1238,8 +1238,8 @@ { "path": "test/service-stdio-package.test.mjs", "before": "5e302462792e360747bf0254d336f8d747f55e4c", - "after": "b2894842f847b77d25377819461ea22c1864e3aeb0032206441d653e431df09d", - "rationale": "Q-GUARD hosted stability repair: retain a finite per-test ceiling with enough saturated Windows runner headroom for the clean clone, install, pack, reinstall, and authenticated process round-trip." + "after": "99e10a2d3613cc429ba954332a88846268cffe6bdef46548c73b510c04d329e3", + "rationale": "Make package setup abort-aware with bounded phase diagnostics, one explicit build, ordered cleanup and safe npm report parsing; retain real authenticated installed-package assertions and unchanged deadlines." }, { "path": "test/watcher-coordinator.test.mjs", diff --git a/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md b/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md index 4bbc0ab..9fe5a54 100644 --- a/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md +++ b/docs/NATIVE-RETRIEVAL-READ-REMEDIATION.md @@ -43,4 +43,24 @@ The experiment is all-public and unfiltered; broader policy behavior is covered by the tests and must remain part of review. No qualification pass is inferred for a newly packed artifact. Adoption requires exact-commit native matrix, observation, package/consumer checks and new24-hour soak evidence. Preserve every -old failure and old-source pass with its original binding. \ No newline at end of file +old failure and old-source pass with its original binding. +## Packaged MCP qualification timeout follow-up + +Exact-source runtime run 34293967667 at f7d80e6 failed on Windows Node 24.19.0. +The packed-MCP test exceeded its 180-second deadline before the outer runner +reached its 30-minute limit. The original log cannot identify which setup phase +stalled. Other passing lanes do not override this failure. + +Replace synchronous setup with abort-aware subprocess calls, emit allowlisted +phase names/status/timings, and build once explicitly between script-disabled +lockfile installation and packing. The authenticated installed-package protocol +assertions and both deadlines remain unchanged. Cleanup closes the bridge and +HTTP server before removing the isolated fixture. Cancellation coverage proves +termination of the direct spawned process; it does not establish descendant +process-tree termination under every build-tool failure. + +Four focused tests pass on native Windows Node 24.18.0 (19.84 seconds) and +22.23.2 (14.18 seconds), including an installed packed bridge exchange. Logs are +package-test-cancellable-node24.log and package-test-cancellable-node22.log in +the external evidence directory. Hosted exact-source qualification remains +required; this repair does not establish the original timeout's precise cause. diff --git a/test/service-stdio-package.test.mjs b/test/service-stdio-package.test.mjs index e266b26..49099db 100644 --- a/test/service-stdio-package.test.mjs +++ b/test/service-stdio-package.test.mjs @@ -1,6 +1,8 @@ +import { promisify } from "node:util"; +import { rm } from "node:fs/promises"; import assert from "node:assert/strict"; -import { execFileSync, spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFile, spawn } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import http from "node:http"; import { tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; @@ -35,6 +37,50 @@ function resolveNpmCli(environment = process.env) { return found; } +const execFileAsync = promisify(execFile); +async function setupPhase(t, phase, executable, args, options = {}, onChild) { + assert.match(phase, /^[a-z_]+$/u); + const start = performance.now(); + t.diagnostic(JSON.stringify({ phase, status: "START" })); + try { + const pending = execFileAsync(executable, args, { ...options, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, windowsHide: true, signal: t.signal }); + onChild?.(pending.child); + const result = await pending; + t.diagnostic(JSON.stringify({ phase, status: "PASS", elapsed_ms: performance.now() - start })); + return result.stdout; + } catch { + t.diagnostic(JSON.stringify({ phase, status: "FAIL", elapsed_ms: performance.now() - start })); + throw new Error("PACKAGE_QUALIFICATION_" + phase.toUpperCase() + "_FAILED"); + } +} +function packedFilename(output) { + const start = output.search(/^[ \t]*[\[{]/mu); + assert.ok(start >= 0, "npm pack did not emit its JSON report"); + const report = JSON.parse(output.slice(start)); + if (Array.isArray(report)) assert.equal(report.length, 1); + const entry = Array.isArray(report) ? report[0] : report; + assert.match(entry?.filename ?? "", /^gkos-engine-[0-9][A-Za-z0-9.+-]*\.tgz$/u); + return entry.filename; +} +test("package setup cancellation terminates the spawned process", async () => { + const controller = new AbortController(); let closed; + const pending = setupPhase({ signal: controller.signal, diagnostic() {} }, "abort_probe", process.execPath, + ["-e", "setInterval(() => {}, 1000)"], {}, child => { + closed = new Promise(resolveClose => child.once("close", resolveClose)); + child.once("spawn", () => controller.abort()); + }); + await assert.rejects(pending, { message: "PACKAGE_QUALIFICATION_ABORT_PROBE_FAILED" }); + await closed; +}); +test("npm pack JSON accepts one array or object report and refuses unsafe filenames", () => { + for (const value of [{ filename: "gkos-engine-2.2.0.tgz" }, [{ filename: "gkos-engine-2.2.0.tgz" }]]) { + assert.equal(packedFilename(JSON.stringify(value)), "gkos-engine-2.2.0.tgz"); + } + for (const value of [[], [{ filename: "a.tgz" }, { filename: "b.tgz" }], { filename: "../outside.tgz" }]) { + assert.throws(() => packedFilename(JSON.stringify(value))); + } +}); + test("npm CLI discovery works outside an npm lifecycle", () => { const npmCli = resolveNpmCli({ ...process.env, npm_execpath: undefined }); assert.match(npmCli.replaceAll("\\", "/"), /\/npm(?:-cli\.js|\/bin\/npm-cli\.js)$/u); @@ -45,22 +91,28 @@ test("npm CLI discovery works outside an npm lifecycle", () => { // per-test ceiling while leaving headroom below the runtime job's outer bound. test("packed installation runs the stdio bridge against one authenticated real process", { timeout: 180_000 }, async (t) => { const temporary = mkdtempSync(join(CANONICAL_TEMPORARY_ROOT, "gkos-stdio-package-")); - t.after(() => rmSync(temporary, { recursive: true, force: true })); + let server, child, bridgeClosed; + t.after(async () => { + if (child && child.exitCode === null && child.signalCode === null) child.kill(); + if (bridgeClosed) await bridgeClosed; + if (server) { + server.closeAllConnections(); + await new Promise(resolveClose => server.close(() => resolveClose())); + } + assert.ok(resolve(temporary).startsWith(CANONICAL_TEMPORARY_ROOT + (process.platform === "win32" ? "\\" : "/"))); + await rm(temporary, { recursive: true, force: true }); + }); const npmCli = resolveNpmCli(); - // npm pack may run prepare even with ignore-scripts on some npm releases. - // Build and pack a clean local clone so its dist recreation cannot race the - // concurrently executing repository test processes. + // Build once in an isolated clean clone. Every setup subprocess observes + // the unchanged test deadline, rather than blocking timeout delivery. const source = join(temporary, "source"); - execFileSync("git", ["clone", "--quiet", "--shared", ROOT, source], { stdio: "pipe" }); - execFileSync(process.execPath, [npmCli, "ci", "--no-audit", "--no-fund"], { cwd: source, stdio: "pipe" }); - const packOutput = execFileSync(process.execPath, [npmCli, "pack", "--json", "--pack-destination", temporary], { cwd: source, encoding: "utf8" }); - const jsonStart = packOutput.indexOf("["); - const jsonEnd = packOutput.lastIndexOf("]"); - assert.ok(jsonStart >= 0 && jsonEnd >= jsonStart, "npm pack did not emit its JSON report"); - const packReport = JSON.parse(packOutput.slice(jsonStart, jsonEnd + 1)); - const archive = join(temporary, packReport[0].filename); + await setupPhase(t, "clone", "git", ["clone", "--quiet", "--shared", ROOT, source]); + await setupPhase(t, "install_source", process.execPath, [npmCli, "ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: source }); + await setupPhase(t, "build_source", process.execPath, ["scripts/build.mjs"], { cwd: source }); + const packOutput = await setupPhase(t, "pack", process.execPath, [npmCli, "pack", "--ignore-scripts", "--json", "--pack-destination", temporary], { cwd: source }); + const archive = join(temporary, packedFilename(packOutput)); writeFileSync(join(temporary, "package.json"), '{"private":true}\n'); - execFileSync(process.execPath, [npmCli, "install", "--ignore-scripts", "--no-audit", "--no-fund", archive], { cwd: temporary, stdio: "pipe" }); + await setupPhase(t, "install_consumer", process.execPath, [npmCli, "install", "--ignore-scripts", "--no-audit", "--no-fund", archive], { cwd: temporary }); const launcher = join(temporary, "node_modules", "gkos-engine", "bin", "gkos-mcp-stdio.mjs"); assert.match(readFileSync(launcher, "utf8"), /service-stdio\.mjs/); @@ -70,7 +122,7 @@ test("packed installation runs the stdio bridge against one authenticated real p if (process.platform !== "win32") chmodSync(tokenFile, 0o600); const session = "018f47a3-7b5e-7c9d-8a1b-123456789abf"; const seen = []; - const server = http.createServer(async (request, response) => { + server = http.createServer(async (request, response) => { const chunks = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; @@ -83,14 +135,17 @@ test("packed installation runs the stdio bridge against one authenticated real p else { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { tools: [] } })); } }); server.listen(0, "127.0.0.1"); - await once(server, "listening"); - t.after(() => { if (server.listening) server.close(); }); + await once(server, "listening", { signal: t.signal }); - const child = spawn(process.execPath, [launcher], { + child = spawn(process.execPath, [launcher], { cwd: temporary, env: { ...process.env, GKOS_MCP_TOKEN_FILE: tokenFile, GKOS_MCP_URL: `http://127.0.0.1:${server.address().port}/mcp` }, stdio: ["pipe", "pipe", "pipe"], + signal: t.signal, + windowsHide: true, }); + bridgeClosed = new Promise(resolveClose => child.once("close", code => resolveClose(code))); + child.on("error", () => {}); // Abort is reported by the unchanged test deadline. const stdout = []; const stderr = []; child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); @@ -101,9 +156,8 @@ test("packed installation runs the stdio bridge against one authenticated real p JSON.stringify({ jsonrpc: "2.0", id: "list", method: "tools/list", params: {} }), "", ].join("\n")); - const [code] = await once(child, "close"); - server.close(); - await once(server, "close"); + const code = await bridgeClosed; + await new Promise(resolveClose => server.close(() => resolveClose())); const out = Buffer.concat(stdout).toString("utf8"); const err = Buffer.concat(stderr).toString("utf8"); assert.equal(code, 0);