diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9f3d712..3d63e0a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 13 October 2026 +- Fixed the Tape Echo browser-preservation check rejecting every module release that changes compiled packages. It now ignores other modules' own rows, version labels and source-tree identity in the shared package files, as the retained-evidence comparison already does, and still requires every other byte, the build manifest hashes, the provenance stamp and unambiguous JSON to match. Tape Echo's own rows, shared payload, compiler identity and qualification fields are unchanged and still checked. It does not re-run the executed parity proof and gives no new hardware evidence; a release that changes shared builder code still needs the owner's binding refresh. + - Fixed beta Octatrack sync work continuing after its review or USB connection ended. Package preparation now returns to review when the selected set or connection changes, and discards results after the dialog closes. Stale USB replies cannot reopen the link, activate a staged package, start a trial or confirm Keep; leaving the last view also releases the trial's tab lock. Synthetic USB and DOM regressions cover these races; physical device recovery and persistence remain unverified by this change. - Documented, in the guides a developer opens for the task, that new Octatrack modules deliver the base/USB lifecycle alongside card builds (companion and DSP ownership on both cores, browser-package and lifecycle evidence, owner review of unavoidable limits), the separation of card and USB compatibility, the Analog BD lifetime gate, the disassembler's exclusive DO-loop endpoint, the Vitest discovery suffix and the GCC/MinGW host C test trap. Corrected the guides' stale blanket "Analog BD refuses every effect" rule. Documentation only. diff --git a/scripts/browser-infrastructure-preservation.mjs b/scripts/browser-infrastructure-preservation.mjs index ec3f97e07..ec83bfdcb 100644 --- a/scripts/browser-infrastructure-preservation.mjs +++ b/scripts/browser-infrastructure-preservation.mjs @@ -5,6 +5,7 @@ import { readFile, readdir, lstat } from 'node:fs/promises' import { resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { PACKAGE_FILES } from './module-source.mjs' +import { engineAsset, otherModuleIds } from './engine-asset.mjs' export const BROWSER_PRESERVATION_PATH = 'sdk/browser-infrastructure-preservation/tapeecho-usb-integration.json' export const BROWSER_PARITY_PATH = 'sdk/browser-infrastructure-preservation/tapeecho-usb-integration-proof.json' @@ -78,36 +79,49 @@ async function fixedJson(root, path) { const bytes = await fixedBytes(root, path) return { bytes, value: canonicalJson(bytes) } } +// JSON.parse keeps the last of two equal keys, which would hide an ambiguous file. +function hasDuplicateKeys(source) { + const stack = [] + for (const [token] of source.matchAll(/"(?:[^"\\]|\\.)*"|[{}[\],:]|[^\s"{}[\],:]+/g)) { + const top = stack[stack.length - 1] + if (token === '{') stack.push({ keys: new Set(), key: true }) + else if (token === '[') stack.push(null) + else if (token === '}' || token === ']') stack.pop() + else if (token === ',' && top) top.key = true + else if (top?.key && token[0] === '"') { if (top.keys.has(token)) return true; top.keys.add(token); top.key = false } + } + return false +} // Release import/stamping changes only provenance already excluded by retained // evidence. Pin all package contents and qualification fields to the actual -// validated Git bytes; derived manifest hashes must match the live files. -async function publicationInputs(root, validatedCommit, validated, current) { - const result = { ...current }, head = git(root, 'rev-parse', 'HEAD').toString().trim() - for (const name of [...PACKAGE_FILES, 'module-build.json']) { +// validated Git bytes; derived manifest hashes must match the live files. Another +// module's own release may also add or change its rows in these shared packages: +// they are not Tape Echo inputs, so the comparison ignores them exactly as the +// retained-evidence comparison does, and every other byte must still match. +async function publicationInputs(root, validatedCommit, validated, current, document) { + const result = { ...current }, head = git(root, 'rev-parse', 'HEAD').toString().trim(), others = await otherModuleIds(root, document, validatedCommit) + for (const name of [...PACKAGE_FILES, 'module-build.json', 'analog-bd-composition-proofs.json']) { const path = 'src/engine/assets/' + name if (current[path] === validated[path]) continue if (!current[path] || !validated[path]) fail() const expectedBytes = git(root, 'show', validatedCommit + ':' + path), liveBytes = await fixedBytes(root, path) - const expected = JSON.parse(expectedBytes.toString('utf8')) - if (name !== 'module-build.json') { - // Preserve Python's original numeric-key ordering as well as payload bytes. - const expectedLine = /^ {2}"sourceCommit": (null|"[a-f0-9]{40}"),$/m.exec(expectedBytes.toString('utf8'))?.[0] - const sourceLine = ' "sourceCommit": ' + JSON.stringify(head) + ',' - if (!expectedLine || liveBytes.toString('utf8').replace(sourceLine, expectedLine) !== expectedBytes.toString('utf8')) fail() - result[path] = validated[path]; continue - } - const live = canonicalJson(liveBytes) - if (live.sourceCommit !== head) fail() + const live = name === 'module-build.json' ? canonicalJson(liveBytes) : JSON.parse(liveBytes.toString('utf8')), same = bytes => JSON.stringify(engineAsset(bytes, path, document, document.version, [], others)) + if (live.sourceCommit !== head && live.sourceCommit !== JSON.parse(expectedBytes.toString('utf8')).sourceCommit) fail() // stamped now, or unchanged if (name === 'module-build.json') { if (!keys(live.files, PACKAGE_FILES) || live.approval !== null && (!object(live.approval) || live.approval.sourceCommit !== head)) fail() for (const file of PACKAGE_FILES) { const bytes = await fixedBytes(root, 'src/engine/assets/' + file), entry = live.files[file] if (!keys(entry, ['bytes', 'sha256']) || entry.bytes !== bytes.length || entry.sha256 !== sha(bytes)) fail() } - live.files = expected.files; live.approval = expected.approval + } else { + // Preserve Python's original numeric-key ordering as well as payload bytes + // for a pure provenance stamp; only another module's rows may reorder them. + const expectedLine = /^ {2}"sourceCommit": (null|"[a-f0-9]{40}"),$/m.exec(expectedBytes.toString('utf8'))?.[0] + const sourceLine = ' "sourceCommit": ' + JSON.stringify(head) + ',' + if (expectedLine && liveBytes.toString('utf8').replace(sourceLine, expectedLine) === expectedBytes.toString('utf8')) { result[path] = validated[path]; continue } + if (hasDuplicateKeys(liveBytes.toString('utf8'))) fail() } - live.sourceCommit = expected.sourceCommit - if (!isDeepStrictEqual(live, expected)) fail() + if (same(liveBytes) !== same(expectedBytes)) fail() result[path] = validated[path] } return sorted(result) @@ -139,7 +153,7 @@ export async function validateBrowserPreservationBinding(root, approvedRef, docu git(root, 'merge-base', '--is-ancestor', record.approvedMain, approvedRef) git(root, 'merge-base', '--is-ancestor', record.approvedMain, record.validatedCommit) git(root, 'merge-base', '--is-ancestor', record.validatedCommit, 'HEAD') - const before = await browserProtectedInputs(root, record.approvedMain), validated = await browserProtectedInputs(root, record.validatedCommit), current = await publicationInputs(root, record.validatedCommit, validated, await browserProtectedInputs(root)) + const before = await browserProtectedInputs(root, record.approvedMain), validated = await browserProtectedInputs(root, record.validatedCommit), current = await publicationInputs(root, record.validatedCommit, validated, await browserProtectedInputs(root), document) if (!object(record.protectedInputHashes) || Object.values(record.protectedInputHashes).some(value => !hash(value)) || !isDeepStrictEqual(sorted(record.protectedInputHashes), current) || !isDeepStrictEqual(validated, current) || record.protectedInputsSha256 !== browserProtectedInputsSha256(current) || record.approvedProtectedInputsSha256 !== browserProtectedInputsSha256(before) || !keys(record.changes, allowed)) fail() const changed = [...new Set([...Object.keys(before), ...Object.keys(current)])].filter(path => before[path] !== current[path]).sort() if (!isDeepStrictEqual(changed, allowed)) fail() diff --git a/scripts/browser-infrastructure-preservation.test.mjs b/scripts/browser-infrastructure-preservation.test.mjs index c5c87f2c0..f52120c1a 100644 --- a/scripts/browser-infrastructure-preservation.test.mjs +++ b/scripts/browser-infrastructure-preservation.test.mjs @@ -26,7 +26,9 @@ async function fixture() { write('.git/objects/info/alternates', resolve(common, 'objects') + '\n') git('symbolic-ref', 'HEAD', 'refs/heads/main'); git('update-ref', 'refs/heads/main', evidence) for (const path of ['sdk/octabam/platform/fixture.txt', 'sdk/octabam/dsp/fixture.txt', 'sdk/octabam/tools/build/fixture.txt', 'sdk/octabam/tools/remix/fixture.txt', 'scripts/build-module-packages.py', 'scripts/module-source.mjs', 'src/engine/assets/legacy-packages.json', ...allowed.filter(path => ['client.ts', 'protocol.ts', 'session.ts', 'digi-build.ts'].includes(path.split('/').at(-1)))]) write(path, 'approved:' + path) - for (const name of PACKAGE_FILES) write('src/engine/assets/' + name, { schema: 1, sourceCommit: null, code: 'synthetic authored bytes', moduleVersions: { tapeecho: document.version } }) + for (const name of PACKAGE_FILES) write('src/engine/assets/' + name, { schema: 1, sourceCommit: null, code: 'synthetic authored bytes', moduleVersions: { tapeecho: document.version }, rows: [{ moduleId: 'tapeecho', bytes: 'authored' }] }) + write('sdk/catalog.json', { modules: [{ id: 'tapeecho' }, { id: 'foreign' }] }) + write('src/engine/assets/analog-bd-composition-proofs.json', { moduleSourceTreeSha256: 'a'.repeat(64), placements: 'authored' }) // Python preserves insertion order for integer-looking keys; JS would reorder them. write('src/engine/assets/menu-recipes.json', '{\n "schema": 1,\n "sourceCommit": null,\n "labels": {\n "7": "---",\n "6": "LADR"\n }\n}\n') const packageFiles = () => Object.fromEntries(PACKAGE_FILES.map(name => { const bytes = readFileSync(resolve(root, 'src/engine/assets/' + name)); return [name, { bytes: bytes.length, sha256: sha(bytes) }] })) @@ -90,6 +92,52 @@ describe('one exact Tape Echo browser infrastructure binding', () => { await expect(loadBrowserInfrastructurePreservation(f.root, f.base, document)).rejects.toThrow('Invalid or stale') } finally { f.cleanup() } }) + // Another module's release adds its own rows to the shared packages; those are not Tape Echo inputs. + const foreignRelease = (f, change = () => {}) => { + f.publish() + const packagePath = 'src/engine/assets/dsp-packages.json', buildPath = 'src/engine/assets/module-build.json', proofsPath = 'src/engine/assets/analog-bd-composition-proofs.json' + const pkg = JSON.parse(readFileSync(resolve(f.root, packagePath))), build = JSON.parse(readFileSync(resolve(f.root, buildPath))) + pkg.moduleVersions.foreign = '1.0.0'; pkg.rows.push({ moduleId: 'foreign', bytes: 'new' }); change(pkg) + f.write(packagePath, pkg) + f.write(proofsPath, { moduleSourceTreeSha256: 'c'.repeat(64), placements: 'authored' }) + f.write(buildPath, { ...build, sourceTreeSha256: 'd'.repeat(64), files: f.packageFiles() }) + return pkg + } + it("accepts another module's own rows, versions and source-tree identity in the shared packages", async () => { + const f = await fixture() + try { foreignRelease(f); expect(await loadBrowserInfrastructurePreservation(f.root, f.base, document)).not.toBeNull() } finally { f.cleanup() } + }) + it.each([ + ['a Tape Echo row', pkg => { pkg.rows[0].bytes = 'changed' }], + ['the shared payload', pkg => { pkg.code += 'changed' }], + ['a foreign provenance commit', pkg => { pkg.sourceCommit = 'f'.repeat(40) }], + ['the Tape Echo version label', pkg => { pkg.moduleVersions.tapeecho = '0.1.4-experimental' }], + ])("still rejects another module's release that also changes %s", async (_, change) => { + const f = await fixture() + try { + foreignRelease(f, change) + f.write('src/engine/assets/module-build.json', { ...JSON.parse(readFileSync(resolve(f.root, 'src/engine/assets/module-build.json'))), files: f.packageFiles() }) + await expect(loadBrowserInfrastructurePreservation(f.root, f.base, document)).rejects.toThrow('Invalid or stale') + } finally { f.cleanup() } + }) + it("rejects another module's release with a duplicate JSON key", async () => { + const f = await fixture() + try { + const pkg = foreignRelease(f), path = 'src/engine/assets/dsp-packages.json' + f.write(path, JSON.stringify(pkg, null, 2).replace('"schema": 1,', '"schema": 1,\n "schema": 1,') + '\n') + f.write('src/engine/assets/module-build.json', { ...JSON.parse(readFileSync(resolve(f.root, 'src/engine/assets/module-build.json'))), files: f.packageFiles() }) + await expect(loadBrowserInfrastructurePreservation(f.root, f.base, document)).rejects.toThrow('Invalid or stale') + } finally { f.cleanup() } + }) + it('rejects a changed shared compiler or Tape Echo build field beside another module\'s release', async () => { + const f = await fixture() + try { + foreignRelease(f) + const path = 'src/engine/assets/module-build.json' + f.write(path, { ...JSON.parse(readFileSync(resolve(f.root, path))), compilerSha256: 'e'.repeat(64) }) + await expect(loadBrowserInfrastructurePreservation(f.root, f.base, document)).rejects.toThrow('Invalid or stale') + } finally { f.cleanup() } + }) it('leaves the original fail-closed path active without a real binding', async () => { const f = await fixture() try { rmSync(resolve(f.root, BROWSER_PRESERVATION_PATH)); expect(await loadBrowserInfrastructurePreservation(f.root, f.base, document)).toBeNull() } finally { f.cleanup() } diff --git a/scripts/engine-asset.mjs b/scripts/engine-asset.mjs new file mode 100644 index 000000000..a24bf5bd5 --- /dev/null +++ b/scripts/engine-asset.mjs @@ -0,0 +1,50 @@ +// Shared by retained evidence and the Tape Echo browser binding: the one definition of +// which package rows belong to other modules and so are not the retained module's inputs. +import { readFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +// Every catalog module other than the retained one, at the evidence commit or +// now. Their package rows and version labels are their own releases, qualified +// on their own; retained evidence freezes its module's package and shared code. +export async function otherModuleIds(root, document, commit) { + const ids = new Set() + const catalogs = [JSON.parse(await readFile(resolve(root, 'sdk/catalog.json'), 'utf8'))] + if (commit) catalogs.push(JSON.parse(execFileSync('git', ['show', commit + ':sdk/catalog.json'], { cwd: root, maxBuffer: 32 * 1024 * 1024 }).toString('utf8'))) + for (const catalog of catalogs) for (const module of catalog.modules) ids.add(module.id) + ids.delete(document.id) + return [...ids] +} +export function engineAsset(bytes, path, document, previousVersion, addedIds = [], otherIds = []) { + const value = JSON.parse(bytes.toString('utf8')) + // Rebuilt package provenance and the version label change for editorial + // releases. Preserve every byte, address, recipe, limit and test verdict. + delete value.sourceCommit + const omitted = [...new Set([...addedIds, ...otherIds])] + for (const id of omitted) if (value.moduleVersions) delete value.moduleVersions[id] + if (value.moduleVersions?.[document.id] === document.version) value.moduleVersions[document.id] = previousVersion + if (path === 'src/engine/assets/module-build.json') { + delete value.approval + delete value.sourceTreeSha256 + if (addedIds.length) delete value.compilerSha256 + delete value.files // Derived file hashes; package payloads are checked below. + // The release independently verifies and stamps owner approval. This + // provenance changes on every release, without changing runtime inputs. + delete value.approval + } + if (path === 'src/engine/assets/analog-bd-composition-proofs.json') { + // Derived inventory identity changes with an editorial version label. The + // native image hashes, placements and verdicts below remain protected. + delete value.moduleSourceTreeSha256 + } + function visit(item) { + if (!item || typeof item !== 'object') return + if ((item.id === document.id || item.moduleId === document.id) && item.version === document.version) item.version = previousVersion + for (const [key, child] of Object.entries(item)) { + if (Array.isArray(child)) item[key] = child.filter(row => !row || typeof row !== 'object' || !omitted.includes(row.moduleId ?? row.id) && !(Array.isArray(row.ids) && row.ids.some(id => omitted.includes(id)))) + visit(item[key]) + } + } + visit(value) + return value +} diff --git a/scripts/retained-evidence.mjs b/scripts/retained-evidence.mjs index d42f84601..975744f82 100644 --- a/scripts/retained-evidence.mjs +++ b/scripts/retained-evidence.mjs @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { resolve, dirname } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { loadBuilderPreservation } from './builder-preservation.mjs' +import { engineAsset, otherModuleIds } from './engine-asset.mjs' import { loadBrowserInfrastructurePreservation } from './browser-infrastructure-preservation.mjs' import { parseModuleDocument } from '../src/catalog/module-contract.ts' import { compareModuleVersions } from '../src/catalog/versions.ts' @@ -81,50 +82,6 @@ function editorial(path, document) { || /^(LICENSE|LICENCE|COPYING)(\..*)?$/i.test(path) || /^media\/.*\.(png|jpg|jpeg|webp|svg|wav|mp3|ogg)$/i.test(path) } -// Every catalog module other than the retained one, at the evidence commit or -// now. Their package rows and version labels are their own releases, qualified -// on their own; retained evidence freezes its module's package and shared code. -async function otherModuleIds(root, document, commit) { - const ids = new Set() - const catalogs = [JSON.parse(await readFile(resolve(root, 'sdk/catalog.json'), 'utf8'))] - if (commit) catalogs.push(JSON.parse(text(root, 'show', commit + ':sdk/catalog.json'))) - for (const catalog of catalogs) for (const module of catalog.modules) ids.add(module.id) - ids.delete(document.id) - return [...ids] -} -function engineAsset(bytes, path, document, previousVersion, addedIds = [], otherIds = []) { - const value = JSON.parse(bytes.toString('utf8')) - // Rebuilt package provenance and the version label change for editorial - // releases. Preserve every byte, address, recipe, limit and test verdict. - delete value.sourceCommit - const omitted = [...new Set([...addedIds, ...otherIds])] - for (const id of omitted) if (value.moduleVersions) delete value.moduleVersions[id] - if (value.moduleVersions?.[document.id] === document.version) value.moduleVersions[document.id] = previousVersion - if (path === 'src/engine/assets/module-build.json') { - delete value.approval - delete value.sourceTreeSha256 - if (addedIds.length) delete value.compilerSha256 - delete value.files // Derived file hashes; package payloads are checked below. - // The release independently verifies and stamps owner approval. This - // provenance changes on every release, without changing runtime inputs. - delete value.approval - } - if (path === 'src/engine/assets/analog-bd-composition-proofs.json') { - // Derived inventory identity changes with an editorial version label. The - // native image hashes, placements and verdicts below remain protected. - delete value.moduleSourceTreeSha256 - } - function visit(item) { - if (!item || typeof item !== 'object') return - if ((item.id === document.id || item.moduleId === document.id) && item.version === document.version) item.version = previousVersion - for (const [key, child] of Object.entries(item)) { - if (Array.isArray(child)) item[key] = child.filter(row => !row || typeof row !== 'object' || !omitted.includes(row.moduleId ?? row.id) && !(Array.isArray(row.ids) && row.ids.some(id => omitted.includes(id)))) - visit(item[key]) - } - } - visit(value) - return value -} function sameFiles(previous, current, skip, label, compare = (a, b) => a.equals(b), preserved = () => false) { const paths = new Set([...previous.keys(), ...current.keys()]) for (const path of paths) { diff --git a/src/catalog/module-publication.test.ts b/src/catalog/module-publication.test.ts index 87c19369e..3e9e148ad 100644 --- a/src/catalog/module-publication.test.ts +++ b/src/catalog/module-publication.test.ts @@ -23,7 +23,7 @@ it('checks real PR publication changes without executing module source', async ( const failure=()=>{const result=run('--base','HEAD','--write');expect(result.status).not.toBe(0);return result.stderr} const git=(...args:string[])=>execFileSync('git',args,{cwd:root,encoding:'utf8'}) try { - for(const path of ['scripts/modules.mjs','scripts/change-scope.mjs','scripts/module-source.mjs','scripts/synth-release.mjs','scripts/module-qualification.mjs','scripts/retained-evidence.mjs','scripts/builder-preservation.mjs','scripts/browser-infrastructure-preservation.mjs','scripts/module-documentation.mjs','src/catalog/module-contract.ts','src/catalog/module-contract-v3.ts','src/catalog/module-authors.ts','src/devices/machine-contract.ts','src/catalog/versions.ts','src/catalog/module-folder.ts','src/catalog/resource-impact.ts','src/catalog/compatibility-checks.ts','sdk/module-release-waivers.json']){ + for(const path of ['scripts/modules.mjs','scripts/change-scope.mjs','scripts/module-source.mjs','scripts/synth-release.mjs','scripts/module-qualification.mjs','scripts/retained-evidence.mjs','scripts/engine-asset.mjs','scripts/builder-preservation.mjs','scripts/browser-infrastructure-preservation.mjs','scripts/module-documentation.mjs','src/catalog/module-contract.ts','src/catalog/module-contract-v3.ts','src/catalog/module-authors.ts','src/devices/machine-contract.ts','src/catalog/versions.ts','src/catalog/module-folder.ts','src/catalog/resource-impact.ts','src/catalog/compatibility-checks.ts','sdk/module-release-waivers.json']){ mkdirSync(dirname(resolve(root,path)),{recursive:true}) copyFileSync(resolve(path),resolve(root,path)) }