From f01c6634c56dc75177c902b342fc185b4cebeb3a Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 29 Aug 2026 19:39:43 -0400 Subject: [PATCH 1/3] feat: prepare Oh adoption candidates --- dist/index.js | 482 ++++++++++++++++++++++ docs/design.md | 19 + package.json | 1 + scripts/npm-release-workflow.test.ts | 4 +- src/index.ts | 1 + src/oh-adoption.test.ts | 138 +++++++ src/oh-adoption.ts | 583 +++++++++++++++++++++++++++ 7 files changed, 1226 insertions(+), 2 deletions(-) create mode 100644 src/oh-adoption.test.ts create mode 100644 src/oh-adoption.ts diff --git a/dist/index.js b/dist/index.js index 4c011e6..1c09763 100644 --- a/dist/index.js +++ b/dist/index.js @@ -245,6 +245,486 @@ import { wikiLinks } from "./index-cxfrakt7.js"; import"./index-1xxnjn0d.js"; +// src/oh-adoption.ts +import { createHash } from "crypto"; +import { posix } from "path"; +var SHA256_PATTERN = /^[0-9a-f]{64}$/u; +var CODE_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u; +var RECORD_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u; +var MAX_CAPSULE_BYTES = 16 * 1024 * 1024; +var MAX_RECORDS = 1024; +var MAX_ROOTS = 256; +var MAX_RECORD_BYTES = 1024 * 1024; +var MAX_DEPENDENCIES = 4096; +var MAX_TEXT_BYTES = 4096; +var MAX_STRUCTURAL_NODES = 262144; +var MAX_STRUCTURAL_DEPTH = 128; +var OH_RECORD_KINDS = new Set([ + "activity", + "assertion", + "context", + "dependency-manifest", + "edition", + "entity", + "evidence", + "identity-operation", + "inquiry", + "inquiry-event", + "review-decision", + "rights-decision", + "schema", + "shape", + "statement", + "type-membership", + "view", + "vocabulary" +]); +function isRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function exactKeys(value, keys) { + const actual = Reflect.ownKeys(value); + return actual.length === keys.length && actual.every((key) => { + if (typeof key !== "string" || !keys.includes(key)) + return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && descriptor.enumerable && "value" in descriptor; + }); +} +function validUnicode(value) { + for (let index = 0;index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 55296 && code <= 56319) { + const next = value.charCodeAt(index + 1); + if (next < 56320 || next > 57343) + return false; + index += 1; + } else if (code >= 56320 && code <= 57343) + return false; + } + return true; +} +function canonicalJson(value, path = "$", ancestors = new Set) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + if (!validUnicode(value)) + throw new TypeError(`${path} contains invalid Unicode.`); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value) || Object.is(value, -0)) + throw new TypeError(`${path} is not a canonical number.`); + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null || ancestors.has(value)) { + throw new TypeError(`${path} is not an acyclic JSON value.`); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const keys2 = Reflect.ownKeys(value); + if (keys2.some((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length))) { + throw new TypeError(`${path} has non-index array properties.`); + } + const output = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) + throw new TypeError(`${path} contains a sparse array.`); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError(`${path}[${index}] is not an enumerable data property.`); + } + output.push(canonicalJson(descriptor.value, `${path}[${index}]`, ancestors)); + } + return `[${output.join(",")}]`; + } + if (!isRecord(value)) + throw new TypeError(`${path} is not a plain JSON object.`); + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) + throw new TypeError(`${path} has symbol properties.`); + const keys = ownKeys; + keys.sort(); + return `{${keys.map((key) => { + if (!validUnicode(key)) + throw new TypeError(`${path} contains an invalid key.`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError(`${path}.${key} is not an enumerable data property.`); + } + return `${JSON.stringify(key)}:${canonicalJson(descriptor.value, `${path}.${key}`, ancestors)}`; + }).join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function digest(value) { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} +function structurallyBounded(value) { + const pending = [[value, 0]]; + const seen = new Set; + let nodes = 0; + while (pending.length > 0) { + const [candidate, depth] = pending.pop(); + nodes += 1; + if (nodes > MAX_STRUCTURAL_NODES || depth > MAX_STRUCTURAL_DEPTH) + return false; + if (typeof candidate === "string" && Buffer.byteLength(candidate, "utf8") > MAX_CAPSULE_BYTES) + return false; + if (typeof candidate !== "object" || candidate === null) + continue; + if (seen.has(candidate)) + return false; + seen.add(candidate); + if (Array.isArray(candidate)) { + if (candidate.length > MAX_STRUCTURAL_NODES) + return false; + const keys = Reflect.ownKeys(candidate); + if (keys.some((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= candidate.length))) + return false; + for (let index = 0;index < candidate.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) + return false; + pending.push([descriptor.value, depth + 1]); + } + } else if (isRecord(candidate)) { + const keys = Reflect.ownKeys(candidate); + if (keys.length > MAX_STRUCTURAL_NODES || keys.some((key) => typeof key !== "string")) + return false; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) + return false; + pending.push([descriptor.value, depth + 1]); + } + } else + return false; + } + return true; +} +function sha(value) { + return typeof value === "string" && SHA256_PATTERN.test(value) ? value : null; +} +function code(value, maximum = 256) { + return typeof value === "string" && value.length <= maximum && CODE_PATTERN.test(value) ? value : null; +} +function recordKey(value) { + return typeof value === "string" && value.length <= 512 && RECORD_KEY_PATTERN.test(value) ? value : null; +} +function orderedUnique(values) { + return values.every((value, index) => index === 0 || values[index - 1] < value); +} +function parseProfile(value) { + if (!isRecord(value) || !exactKeys(value, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "profileSha256", + "v" + ]) || value.v !== 1 || value.profileKind !== "working" || !isRecord(value.capabilities) || !exactKeys(value.capabilities, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ])) + return null; + const capabilities = value.capabilities; + if (capabilities.changesSince !== true || capabilities.dependencyClosureExport !== true || capabilities.exactSnapshots !== true || capabilities.operationReplication !== false || capabilities.semanticBundleCommit !== true || capabilities.v !== 1 || capabilities.wholeSpacePurge !== true) + return null; + const applicationProfileSha256 = value.applicationProfileSha256 === null ? null : sha(value.applicationProfileSha256); + const profileId = code(value.profileId); + const profileSha256 = sha(value.profileSha256); + if (value.applicationProfileSha256 !== null && applicationProfileSha256 === null || profileId === null || profileSha256 === null) + return null; + const payload = { applicationProfileSha256, capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, profileId, profileKind: "working", v: 1 }; + return digest(payload) === profileSha256 ? { ...payload, profileSha256 } : null; +} +function parseBinding(value) { + if (!isRecord(value) || !exactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = sha(value.bindingSha256); + const contractSha256 = sha(value.contractSha256); + const profile = parseProfile(value.profile); + const realmId = code(value.realmId); + const spaceId = code(value.spaceId); + if (bindingSha256 === null || contractSha256 === null || profile === null || realmId === null || spaceId === null) { + return null; + } + const payload = { contractSha256, profile, realmId, spaceId, v: 1 }; + return digest(payload) === bindingSha256 ? { ...payload, bindingSha256 } : null; +} +function parseHead(value) { + if (!isRecord(value) || !exactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : sha(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : sha(value.operationSha256); + const recordsSha256 = sha(value.recordsSha256); + return generation !== null && generation === sequence && recordsSha256 !== null && (value.graphRevisionSha256 === null || graphRevisionSha256 !== null) && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) && sequence === 0 === (graphRevisionSha256 === null) ? { generation, graphRevisionSha256, operationSha256, recordsSha256, sequence, v: 1 } : null; +} +function parseRecord(value) { + if (!isRecord(value) || !exactKeys(value, ["dependencies", "key", "kind", "recordSha256", "v", "value"]) || value.v !== 1 || !Array.isArray(value.dependencies) || value.dependencies.length > MAX_DEPENDENCIES || !OH_RECORD_KINDS.has(value.kind) || !structurallyBounded(value.value)) + return null; + const key = recordKey(value.key); + const kind = typeof value.kind === "string" ? value.kind : null; + const recordSha256 = sha(value.recordSha256); + const dependencies = value.dependencies.map(recordKey); + if (key === null || kind === null || recordSha256 === null || dependencies.some((item) => item === null) || !orderedUnique(dependencies) || dependencies.includes(key)) + return null; + const payload = { + dependencies, + key, + kind, + v: 1, + value: value.value + }; + const encoded = canonicalJson(payload.value); + return Buffer.byteLength(encoded, "utf8") <= MAX_RECORD_BYTES && digest(payload) === recordSha256 ? { ...payload, recordSha256 } : null; +} +function parseExpectedSource(value) { + if (!isRecord(value) || !exactKeys(value, ["authorityId", "binding", "head", "v"]) || value.v !== 1) + return null; + const authorityId = code(value.authorityId); + const binding = parseBinding(value.binding); + const head = parseHead(value.head); + return authorityId !== null && binding !== null && head !== null ? { authorityId, binding, head, v: 1 } : null; +} +function parseOhDependencyClosureCapsuleV1(value, expectedSource) { + try { + if (!structurallyBounded(value) || Buffer.byteLength(canonicalJson(value), "utf8") > MAX_CAPSULE_BYTES || !isRecord(value) || !exactKeys(value, ["binding", "closureSha256", "head", "records", "roots", "v"]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length < 1 || value.records.length > MAX_RECORDS || value.roots.length < 1 || value.roots.length > MAX_ROOTS) + return null; + const expected = parseExpectedSource(expectedSource); + const binding = parseBinding(value.binding); + const head = parseHead(value.head); + const closureSha256 = sha(value.closureSha256); + const roots = value.roots.map(recordKey); + const records = value.records.map(parseRecord); + if (expected === null || binding === null || head === null || closureSha256 === null || roots.some((root) => root === null) || !orderedUnique(roots) || records.some((record) => record === null)) + return null; + const parsedRecords = records; + if (!orderedUnique(parsedRecords.map((record) => record.key))) + return null; + if (canonicalJson(binding) !== canonicalJson(expected.binding) || canonicalJson(head) !== canonicalJson(expected.head)) + return null; + const byKey = new Map(parsedRecords.map((record) => [record.key, record])); + const reachable = new Set; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (reachable.has(key)) + continue; + const record = byKey.get(key); + if (record === undefined) + return null; + reachable.add(key); + pending.push(...record.dependencies); + } + if (reachable.size !== parsedRecords.length) + return null; + const payload = { binding, head, records: parsedRecords, roots, v: 1 }; + return digest(payload) === closureSha256 ? { ...payload, closureSha256 } : null; + } catch { + return null; + } +} +function singleLine(value) { + if (typeof value !== "string" || value.length < 1 || value.normalize("NFC") !== value || !validUnicode(value) || /[\u0000-\u001f\u007f-\u009f]/u.test(value) || Buffer.byteLength(value, "utf8") > MAX_TEXT_BYTES) + return null; + return value; +} +function parseDestination(value) { + if (!isRecord(value) || !exactKeys(value, ["purpose", "targetPath", "v"]) || value.v !== 1) + return null; + const purpose = code(value.purpose); + if (purpose === null || typeof value.targetPath !== "string" || value.targetPath.length > 512 || value.targetPath.includes("\\") || value.targetPath.startsWith("/") || posix.normalize(value.targetPath) !== value.targetPath || !/^notes\/[a-z0-9][a-z0-9._/-]*\.md$/u.test(value.targetPath) || value.targetPath.split("/").some((segment) => segment === "." || segment === ".." || segment.startsWith("."))) { + return null; + } + return { purpose, targetPath: value.targetPath, v: 1 }; +} +function parseRights(value, purpose) { + if (!isRecord(value) || !exactKeys(value, ["decisionId", "disposition", "purpose", "v"]) || value.v !== 1 || value.disposition !== "cleared-for-purpose" || value.purpose !== purpose) + return null; + const decisionId = code(value.decisionId); + return decisionId === null ? null : { decisionId, disposition: "cleared-for-purpose", purpose, v: 1 }; +} +function parseReview(value) { + if (!isRecord(value) || !exactKeys(value, ["route", "status", "v"]) || value.v !== 1 || value.status !== "required") + return null; + const route = code(value.route); + return route === null ? null : { route, status: "required", v: 1 }; +} +function parseConflicts(value) { + if (!isRecord(value) || !exactKeys(value, ["notes", "status", "v"]) || value.v !== 1 || value.status !== "none-observed" && value.status !== "requires-resolution" || !Array.isArray(value.notes) || value.notes.length < 1 || value.notes.length > 64) + return null; + const notes = value.notes.map(singleLine); + if (notes.some((note) => note === null)) + return null; + const sorted = [...notes].sort(); + return orderedUnique(sorted) ? { notes: sorted, status: value.status, v: 1 } : null; +} +function parseDisclosures(value, keys) { + if (!Array.isArray(value) || value.length > 256) + return null; + const parsed = []; + for (const item of value) { + if (!isRecord(item) || !exactKeys(item, ["id", "recordKey", "summary", "v"]) || item.v !== 1) + return null; + const id = code(item.id); + const key = recordKey(item.recordKey); + const summary = singleLine(item.summary); + if (id === null || key === null || summary === null || !keys.has(key)) + return null; + parsed.push({ id, recordKey: key, summary, v: 1 }); + } + parsed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0); + return orderedUnique(parsed.map((item) => item.id)) ? parsed : null; +} +function markdownEscape(value) { + return value.replace(/[\\`*_{}\[\]<>()#+.!|>-]/gu, "\\$&"); +} +function renderMarkdown(manifest, candidateSha256) { + const lines = [ + "# Oh adoption candidate", + "", + `- Status: \`${manifest.status}\``, + `- Candidate: \`sha256:${candidateSha256}\``, + `- Destination: \`${manifest.destination.targetPath}\``, + `- Purpose: \`${manifest.destination.purpose}\``, + `- Source authority: \`${manifest.source.authorityId}\``, + `- Source binding: \`${manifest.source.binding.bindingSha256}\``, + `- Source head sequence: \`${manifest.source.head.sequence}\``, + `- Source head operation: \`${manifest.source.head.operationSha256 ?? "empty"}\``, + `- Source graph revision: \`${manifest.source.head.graphRevisionSha256 ?? "empty"}\``, + `- Source records digest: \`${manifest.source.head.recordsSha256}\``, + `- Closure: \`${manifest.source.closureSha256}\``, + "", + "This is a review candidate, not reviewed knowledge. It does not mutate a vault or adopt the source operation chain, database, projection, or derived tuples.", + "", + "## Required decisions", + "", + `- Rights: \`${manifest.rights.disposition}\` via \`${manifest.rights.decisionId}\` for \`${manifest.rights.purpose}\``, + `- Review: \`${manifest.review.status}\` via \`${manifest.review.route}\``, + `- Conflicts: \`${manifest.conflicts.status}\``, + ...manifest.conflicts.notes.map((note) => ` - ${markdownEscape(note)}`), + "", + "## Selected roots", + "", + ...manifest.source.roots.map((root) => `- \`${root}\``), + "", + "## Exact source records", + "" + ]; + for (const record of manifest.source.records) { + lines.push(`### \`${record.key}\``, "", `- Kind: \`${record.kind}\``, `- Digest: \`${record.recordSha256}\``, `- Dependencies: ${record.dependencies.length === 0 ? "none" : record.dependencies.map((key) => `\`${key}\``).join(", ")}`, ""); + } + lines.push("## Transformations", "", ...manifest.transformations.length === 0 ? ["- None declared."] : manifest.transformations.map((item) => `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`), "", "## Redactions", "", ...manifest.redactions.length === 0 ? ["- None declared."] : manifest.redactions.map((item) => `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`), ""); + return `${lines.join(` +`)} +`; +} +function prepareOhAdoptionCandidateV1(value) { + if (!isRecord(value) || !exactKeys(value, [ + "capsule", + "conflicts", + "destination", + "expectedSource", + "redactions", + "review", + "rights", + "transformations", + "v" + ]) || value.v !== 1) { + throw new TypeError("Invalid Oh adoption candidate input."); + } + const expectedSource = parseExpectedSource(value.expectedSource); + const capsule = parseOhDependencyClosureCapsuleV1(value.capsule, value.expectedSource); + const destination = parseDestination(value.destination); + if (expectedSource === null || capsule === null || destination === null) { + throw new TypeError("The source capsule or destination is invalid."); + } + const rights = parseRights(value.rights, destination.purpose); + const review = parseReview(value.review); + const conflicts = parseConflicts(value.conflicts); + const recordKeys = new Set(capsule.records.map((record) => record.key)); + const transformations = parseDisclosures(value.transformations, recordKeys); + const redactions = parseDisclosures(value.redactions, recordKeys); + const roots = new Set(capsule.roots); + if (rights === null || review === null || conflicts === null || transformations === null || redactions === null || capsule.records.filter((record) => roots.has(record.key)).every((record) => record.kind === "view")) { + throw new TypeError("Adoption requires rights, review, conflict, and authoritative-root declarations."); + } + const source = { + authorityId: expectedSource.authorityId, + binding: capsule.binding, + closureSha256: capsule.closureSha256, + head: capsule.head, + records: capsule.records.map((record) => ({ + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + recordSha256: record.recordSha256, + v: 1 + })), + roots: capsule.roots, + v: 1 + }; + const manifest = { + conflicts, + destination, + format: "hraness.kb.oh-adoption-candidate.v1", + redactions, + review, + rights, + source, + status: "prepared", + transformations, + v: 1 + }; + const candidateSha256 = digest(manifest); + const markdown = renderMarkdown(manifest, candidateSha256); + if (Buffer.byteLength(markdown, "utf8") > MAX_CAPSULE_BYTES) { + throw new RangeError("The adoption candidate exceeds its Markdown byte limit."); + } + return { + artifactSha256: createHash("sha256").update(markdown).digest("hex"), + candidateSha256, + manifest, + markdown, + v: 1 + }; +} export { workflowFromUnknown, wikiLinks, @@ -282,9 +762,11 @@ export { readVaultNotes, queryVault, qmdIndexerVersion, + prepareOhAdoptionCandidateV1, planStatuses, percolateVault, parseRetrievalEvaluationCorpus, + parseOhDependencyClosureCapsuleV1, parseNote, parseLocalAttachmentReferences, parseGitHistoryOutput, diff --git a/docs/design.md b/docs/design.md index b2df449..b8dbc1a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -217,6 +217,25 @@ and avoids a repository-wide merge hotspot. A future cache may live outside the vault only if measurements justify it; it must be content-addressed by source and analysis version and rebuild on any mismatch. +## Oh adoption stops at a review candidate + +`prepareOhAdoptionCandidateV1` accepts one dependency-closed capsule from an +Oh working authority and produces deterministic Markdown for destination-owned +review. The caller must pin the exact expected source binding and head, state +the destination purpose and proposed `notes/` path, provide a purpose-matched +rights decision, name the required review route, and record the conflict +assessment and any transformations or redactions. The parser rejects a +tampered, incomplete, over-complete, wrong-authority, or derived-only capsule. + +The returned status is always `prepared`. The function does not open a vault, +write a note, invoke Git, import an operation chain or database, retain a +projection, or write to canonical Oh. A reviewer must inspect the candidate and +author destination Markdown through KB's existing revision-checked write path; +the source's proposed assertion is never relabeled as reviewed knowledge. The +temporary structural verifier is intentionally narrow and must be replaced by +the immutable `@hraness/oh` dependency-closure verifier once version 0.2.0 is +published and pinned. + ## Catalog ownership is explicit A managed vault gives one marked region in `index.md` to the tool. `kb refresh` diff --git a/package.json b/package.json index 772a15d..90c97af 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "src/init.ts", "src/navigation.ts", "src/note-lock.ts", + "src/oh-adoption.ts", "src/percolate.ts", "src/portfolio.ts", "src/portfolio-audit.ts", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 14c0727..21f4316 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -409,8 +409,8 @@ describe("canonical npm package identity", () => { sourcePackJson, }); const verified = await verifyNpmPackageIdentity(validInput); - expect(verified.fileCount).toBe(200); - expect(verified.unpackedBytes).toBe(4_861_496); + expect(verified.fileCount).toBe(201); + expect(verified.unpackedBytes).toBe(4_909_145); expect(verified.sourceArchiveSha512).not.toBe(verified.registryArchiveSha512); const originalTar = gunzipSync(sourceBytes); diff --git a/src/index.ts b/src/index.ts index 27e2d0d..eabd179 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from "./git.js"; export * from "./graph.js"; export * from "./init.js"; export * from "./navigation.js"; +export * from "./oh-adoption.js"; export * from "./percolate.js"; export * from "./query.js"; export * from "./repository-memory.js"; diff --git a/src/oh-adoption.test.ts b/src/oh-adoption.test.ts new file mode 100644 index 0000000..73ed6e0 --- /dev/null +++ b/src/oh-adoption.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; + +import { + parseOhDependencyClosureCapsuleV1, + prepareOhAdoptionCandidateV1, +} from "./oh-adoption.js"; + +function canonical(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number") { + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; +} + +function sha(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +function fixture(kind = "assertion") { + const capabilities = { changesSince: true, dependencyClosureExport: true, exactSnapshots: true, + operationReplication: false, semanticBundleCommit: true, v: 1, wholeSpacePurge: true } as const; + const profilePayload = { applicationProfileSha256: null, capabilities, profileId: "kb.working.v1", + profileKind: "working", v: 1 } as const; + const profile = { ...profilePayload, profileSha256: sha(profilePayload) }; + const bindingPayload = { contractSha256: "a".repeat(64), profile, realmId: "tenant:test/thread:one", + spaceId: "thread:one", v: 1 } as const; + const binding = { ...bindingPayload, bindingSha256: sha(bindingPayload) }; + const evidencePayload = { dependencies: [], key: "evidence:source", kind: "evidence", v: 1, + value: { locator: "https://example.test/source" } } as const; + const evidence = { ...evidencePayload, recordSha256: sha(evidencePayload) }; + const assertionPayload = { dependencies: ["evidence:source"], key: "assertion:candidate", kind, v: 1, + value: { state: "proposed", text: "A bounded candidate." } } as const; + const assertion = { ...assertionPayload, recordSha256: sha(assertionPayload) }; + const records = [assertion, evidence]; + const recordRefs = records.map((record) => ({ dependencies: record.dependencies, key: record.key, + kind: record.kind, sha256: record.recordSha256, v: 1 })); + const head = { generation: 3, graphRevisionSha256: "b".repeat(64), operationSha256: "c".repeat(64), + recordsSha256: sha(recordRefs), sequence: 3, v: 1 } as const; + const capsulePayload = { binding, head, records, roots: ["assertion:candidate"], v: 1 } as const; + const capsule = { ...capsulePayload, closureSha256: sha(capsulePayload) }; + const expectedSource = { authorityId: "sponge.working.primary", binding, head, v: 1 } as const; + const input = { + capsule, + conflicts: { notes: ["No destination collision was found; review must confirm."], + status: "none-observed", v: 1 }, + destination: { purpose: "kb.maintained-knowledge", targetPath: "notes/adopted-candidate.md", v: 1 }, + expectedSource, + redactions: [], + review: { route: "kb.adoption-review", status: "required", v: 1 }, + rights: { decisionId: "rights:decision-one", disposition: "cleared-for-purpose", + purpose: "kb.maintained-knowledge", v: 1 }, + transformations: [{ id: "transform:normalize-title", recordKey: "assertion:candidate", + summary: "Normalize the title without changing the proposed claim.", v: 1 }], + v: 1, + } as const; + return { assertion, binding, capsule, evidence, expectedSource, head, input }; +} + +describe("Oh dependency-closure adoption", () => { + test("prepares deterministic review-only Markdown without laundering proposal authority", () => { + const { input } = fixture(); + const first = prepareOhAdoptionCandidateV1(input); + const second = prepareOhAdoptionCandidateV1({ ...input, + transformations: [...input.transformations].reverse() }); + expect(second).toEqual(first); + expect(first.manifest.status).toBe("prepared"); + expect(first.manifest.review.status).toBe("required"); + expect(first.markdown).toContain("This is a review candidate, not reviewed knowledge."); + expect(first.markdown).toContain("does not mutate a vault"); + expect(first.markdown).not.toContain("status: reviewed"); + expect(first.artifactSha256).toBe(createHash("sha256").update(first.markdown).digest("hex")); + expect(Object.keys(first)).toEqual(["artifactSha256", "candidateSha256", "manifest", "markdown", "v"]); + }); + + test("verifies the exact expected binding and head and rejects closure tampering", () => { + const { assertion, capsule, evidence, expectedSource, head } = fixture(); + expect(parseOhDependencyClosureCapsuleV1(capsule, expectedSource)).toEqual(capsule); + expect(parseOhDependencyClosureCapsuleV1(capsule, { ...expectedSource, + head: { ...head, operationSha256: "d".repeat(64) } })).toBeNull(); + expect(parseOhDependencyClosureCapsuleV1(capsule, { ...expectedSource, + binding: { ...expectedSource.binding, bindingSha256: "e".repeat(64) } })).toBeNull(); + expect(parseOhDependencyClosureCapsuleV1({ ...capsule, + records: [{ ...assertion, value: { state: "reviewed", text: "Tampered." } }, evidence] }, expectedSource)).toBeNull(); + expect(parseOhDependencyClosureCapsuleV1({ ...capsule, records: [assertion] }, expectedSource)).toBeNull(); + const extraPayload = { dependencies: [], key: "entity:smuggled", kind: "entity", v: 1, + value: { name: "Smuggled" } } as const; + const extra = { ...extraPayload, recordSha256: sha(extraPayload) }; + const extraCapsulePayload = { binding: capsule.binding, head: capsule.head, + records: [...capsule.records, extra].sort((left, right) => left.key.localeCompare(right.key)), + roots: capsule.roots, v: 1 } as const; + expect(parseOhDependencyClosureCapsuleV1({ ...extraCapsulePayload, + closureSha256: sha(extraCapsulePayload) }, expectedSource)).toBeNull(); + }); + + test("fails closed on unsafe targets, missing policy, derived-only roots, and projection-shaped inputs", () => { + const { capsule, expectedSource, input } = fixture(); + for (const targetPath of ["../notes/out.md", "/tmp/out.md", "notes/../../out.md", "plans/out.md", "notes/out.txt"]) { + expect(() => prepareOhAdoptionCandidateV1({ ...input, + destination: { ...input.destination, targetPath } })).toThrow("source capsule or destination"); + } + const { rights: _rights, ...withoutRights } = input; + expect(() => prepareOhAdoptionCandidateV1(withoutRights)).toThrow("Invalid Oh adoption"); + expect(() => prepareOhAdoptionCandidateV1({ ...input, + review: { route: "kb.adoption-review", status: "reviewed", v: 1 } })).toThrow("requires rights"); + const derived = fixture("view"); + expect(() => prepareOhAdoptionCandidateV1(derived.input)).toThrow("authoritative-root"); + expect(parseOhDependencyClosureCapsuleV1({ authority: "derived", rows: [], v: 1 }, expectedSource)).toBeNull(); + expect(parseOhDependencyClosureCapsuleV1({ ...capsule, projection: { rows: [] } }, expectedSource)).toBeNull(); + }); + + test("requires disclosures to reference exact capsule records and escapes review prose", () => { + const { input } = fixture(); + expect(() => prepareOhAdoptionCandidateV1({ ...input, + redactions: [{ id: "redact:one", recordKey: "entity:outside", summary: "Remove", v: 1 }] })) + .toThrow("requires rights"); + const candidate = prepareOhAdoptionCandidateV1({ ...input, + conflicts: { notes: [""], status: "requires-resolution", v: 1 } }); + expect(candidate.markdown).not.toContain(""], status: "requires-resolution", v: 1 } }); + .toThrow("valid disclosures"); + const candidate = createOhAdoptionPreparerV1({ ...hostPolicy, + conflicts: { notes: [""], status: "requires-resolution", v: 1 } }) + .prepare(prepareInput); expect(candidate.markdown).not.toContain("