From f6f2964a14d3468779c17a1d804d94d85d7c452e Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 29 Aug 2026 18:01:44 -0400 Subject: [PATCH 1/3] feat(projection): add bounded recursive views --- README.md | 111 +- bun.lock | 7 + dist/projection-public.d.ts | 54 + dist/projection-public.d.ts.map | 1 + dist/projection-public.js | 1990 ++++++++++++++++++++++++++++ dist/projection-suss.d.ts | 17 + dist/projection-suss.d.ts.map | 1 + dist/projection-suss.js | 2026 +++++++++++++++++++++++++++++ dist/projection.d.ts | 282 ++++ dist/projection.d.ts.map | 1 + package.json | 18 +- scripts/projection-node.test.mjs | 44 + site/app/spec/page.tsx | 21 +- site/public/spec/README.md | 1 + site/public/spec/v1/projection.md | 121 ++ spec/README.md | 1 + spec/v1/projection.md | 121 ++ src/projection-public.ts | 51 + src/projection-suss.ts | 123 ++ src/projection.test.ts | 286 ++++ src/projection.ts | 1090 ++++++++++++++++ tests/public-surface.test.ts | 8 +- 22 files changed, 6368 insertions(+), 7 deletions(-) create mode 100644 dist/projection-public.d.ts create mode 100644 dist/projection-public.d.ts.map create mode 100644 dist/projection-public.js create mode 100644 dist/projection-suss.d.ts create mode 100644 dist/projection-suss.d.ts.map create mode 100644 dist/projection-suss.js create mode 100644 dist/projection.d.ts create mode 100644 dist/projection.d.ts.map create mode 100644 scripts/projection-node.test.mjs create mode 100644 site/public/spec/v1/projection.md create mode 100644 spec/v1/projection.md create mode 100644 src/projection-public.ts create mode 100644 src/projection-suss.ts create mode 100644 src/projection.test.ts create mode 100644 src/projection.ts diff --git a/README.md b/README.md index db0ef27..42bd3b5 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ indexes derived and replaceable. - **Treat search as a view.** FTS5 documents and optional local embeddings are derived from current record digests, so either index can be rebuilt without becoming graph authority. +- **Derive without silently asserting.** Positive recursive rules run against + one exact graph head and fact-pack digest. Their tuples and bounded proofs + are deterministic, disposable output rather than accepted graph records. ## Install and first run @@ -133,7 +136,107 @@ intended change, then submit a new operation. The root entrypoint exports canonical JSON, ontology, schema, graph, operation, and sync contracts. Use `@hraness/oh/sqlite` for the local store, `@hraness/oh/sdk` for the `Oh` facade, `@hraness/oh/sync` for transport seams, -and `@hraness/oh/semantic` for the optional local embedding backend. +`@hraness/oh/projection` for recursive derived views, and +`@hraness/oh/semantic` for the optional local embedding backend. + +## Derive an exact projection + +The projection subpath is pure TypeScript and runs in Node 24 serverless +functions without loading SQLite. A snapshot binds the current space head and +complete record-reference set. A fact pack binds the deterministic extractor +that translated those records into relations. Rules and queries are typed data, +not strings or executable callbacks. + +```ts +import { + OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1, + createOhProjectionDatasetV1, + createOhProjectionLiteralV1, + createOhProjectionQueryV1, + createOhProjectionRecordFactsV1, + createOhProjectionRulePackV1, + createOhProjectionRuleV1, + createOhProjectionSnapshotV1, + evaluateOhProjectionV1, + ohProjectionVariableV1 as variable, +} from "@hraness/oh/projection"; + +const records = oh.store.snapshotRecords(); +const snapshot = createOhProjectionSnapshotV1({ + head: oh.head(), + records, + spaceId: oh.store.spaceId, +}); +const dataset = createOhProjectionDatasetV1({ + extractorSha256: OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1.extractorSha256, + factPackId: OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1.factPackId, + factPackRevision: OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1.factPackRevision, + facts: createOhProjectionRecordFactsV1(records), + snapshot, +}); + +const x = variable("x"); +const y = variable("y"); +const z = variable("z"); +const literal = (relation: string, ...terms: ReturnType[]) => + createOhProjectionLiteralV1({ relation, terms }); +const rulePack = createOhProjectionRulePackV1({ + rulePackId: "example.dependencies", + rulePackRevision: 1, + rules: [ + createOhProjectionRuleV1({ + body: [literal("oh.dependency", x, y)], + head: literal("depends", x, y), + ruleId: "depends.direct", + }), + createOhProjectionRuleV1({ + body: [literal("depends", x, y), literal("oh.dependency", y, z)], + head: literal("depends", x, z), + ruleId: "depends.transitive", + }), + ], +}); +const query = createOhProjectionQueryV1({ + find: ["x", "z"], + queryId: "all.dependencies", + where: [literal("depends", x, z)], +}); + +const result = evaluateOhProjectionV1({ dataset, query, rulePack, snapshot }); +console.log(result.rows); +``` + +`result.authority` is always `derived`. Oh does not commit a result, elevate an +agent assertion, or make a proof authoritative. Changing the snapshot, +extracted fact set, rule pack, or query produces a new identity and requires a +full rebuild. + +The reference evaluator favors bounded, transparent correctness. It supports +positive recursion and set semantics; it does not yet support negation, +aggregation, arithmetic, or incremental invalidation. An optional compatibility +lane evaluates the same rules with exactly `@suss/datalog@0.20.0` and returns a +result only after every relation agrees with the reference evaluator: + +```sh +bun add @suss/datalog@0.20.0 +``` + +```ts +import { evaluateOhProjectionWithSussV1 } from "@hraness/oh/experimental/projection-suss"; + +const checked = evaluateOhProjectionWithSussV1({ + dataset, + query, + rulePack, + snapshot, +}); +``` + +Suss does not expose an execution-budget hook. Its adapter therefore applies a +conservative finite-domain admission bound and refuses programs it cannot prove +will stay inside the requested tuple ceiling. The built-in evaluator remains +available for those programs. The compatibility lane deliberately runs both +engines; it is an equivalence check, not a performance backend. ## Add local semantic search @@ -219,6 +322,9 @@ For offline transfer, `oh sync export` writes a bounded bundle to stdout and control, tenant isolation, backup, retry, and remote availability. - Divergent histories do not merge automatically. Oh returns an explicit conflict and leaves reconciliation policy to the consumer. +- Projection tuples and proofs are derived cache output. Persisting or + publishing them as knowledge requires an explicit application-level review + and a new authoritative graph operation. Read [SECURITY.md](SECURITY.md) for the complete public threat model. @@ -243,7 +349,7 @@ Do not create or modify an Oh database until I name its path and ask you to. - **Install and prove the local path:** follow [Install and first run](#install-and-first-run). - **Embed Oh in a tool:** use [the SDK](#use-the-sdk), then select the narrow - package subpath for SQLite, sync, or optional semantics. + package subpath for SQLite, sync, projection, or optional semantics. - **Give Oh to an agent:** install the [Oh Agent Skill](skills/oh/SKILL.md) and keep its database, space, sync target, and mutation authority explicit. - **Implement or change a contract:** begin with the @@ -265,6 +371,7 @@ document. The current contract is V1: - [SQLite storage](spec/v1/storage.md) - [Sync protocol](spec/v1/sync.md) - [Local embedding profile](spec/v1/embedding.md) +- [Derived projections](spec/v1/projection.md) - [Compatibility and migration](spec/v1/migration.md) The JSON Schemas describe exchange envelopes. Runtime parsers additionally diff --git a/bun.lock b/bun.lock index ad731ca..fe45df0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,18 +5,25 @@ "": { "name": "@hraness/oh", "devDependencies": { + "@suss/datalog": "0.20.0", "@types/bun": "1.3.14", "typescript": "5.9.3", }, "peerDependencies": { + "@libsql/client": ">=0.17.4 <1", + "@suss/datalog": "0.20.0", "@tobilu/qmd": "2.5.3", }, "optionalPeers": [ + "@libsql/client", + "@suss/datalog", "@tobilu/qmd", ], }, }, "packages": { + "@suss/datalog": ["@suss/datalog@0.20.0", "", {}, "sha512-a9NM+MwvnMqGkeV1EJlaJ8I68kcNcdVZsZJinCd05weLD2Flr6ck1Awwmi9uoznfapVwFVQq4iCTBLDPWPtIWQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], diff --git a/dist/projection-public.d.ts b/dist/projection-public.d.ts new file mode 100644 index 0000000..dc7b9b1 --- /dev/null +++ b/dist/projection-public.d.ts @@ -0,0 +1,54 @@ +import * as Projection from "./projection"; +export declare const OH_PROJECTION_FORMAT_VERSION_V1: 1; +export declare const OH_PROJECTION_INTERNAL_ENGINE_V1: "oh.naive.positive.v1"; +export declare const OH_PROJECTION_LIMITS_V1: Readonly<{ + arity: 32; + atomBytes: number; + derivedTuples: 262144; + facts: 262144; + literalsPerRule: 64; + proofDepth: 128; + proofNodes: 4096; + queryLiterals: 64; + queryMatches: 262144; + queryResults: 65536; + relations: 4096; + rounds: 1024; + rules: 1024; + sourcesPerFact: 64; + variables: 256; +}>; +export declare const OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1: Readonly<{ + extractorSha256: import("./canonical").Sha256Hex; + factPackId: "oh.record-facts"; + factPackRevision: 1; + relations: readonly ["oh.dependency", "oh.record"]; + semantics: "oh.projection.positive-datalog.v1"; + v: 1; +}>; +export declare const OH_PROJECTION_SEMANTICS_V1: "oh.projection.positive-datalog.v1"; +export declare const createOhProjectionDatasetV1: typeof Projection.createOhProjectionDatasetV1; +export declare const createOhProjectionFactV1: typeof Projection.createOhProjectionFactV1; +export declare const createOhProjectionIdentityV1: typeof Projection.createOhProjectionIdentityV1; +export declare const createOhProjectionLiteralV1: typeof Projection.createOhProjectionLiteralV1; +export declare const createOhProjectionQueryV1: typeof Projection.createOhProjectionQueryV1; +export declare const createOhProjectionRecordFactsV1: typeof Projection.createOhProjectionRecordFactsV1; +export declare const createOhProjectionRulePackV1: typeof Projection.createOhProjectionRulePackV1; +export declare const createOhProjectionRuleV1: typeof Projection.createOhProjectionRuleV1; +export declare const createOhProjectionSnapshotV1: typeof Projection.createOhProjectionSnapshotV1; +export declare const evaluateOhProjectionV1: typeof Projection.evaluateOhProjectionV1; +export declare const invalidationForOhProjectionV1: typeof Projection.invalidationForOhProjectionV1; +export declare const isOhProjectionRecordKindV1: typeof Projection.isOhProjectionRecordKindV1; +export declare const ohProjectionConstantV1: typeof Projection.ohProjectionConstantV1; +export declare const ohProjectionVariableV1: typeof Projection.ohProjectionVariableV1; +export declare const parseOhProjectionDatasetV1: typeof Projection.parseOhProjectionDatasetV1; +export declare const parseOhProjectionFactV1: typeof Projection.parseOhProjectionFactV1; +export declare const parseOhProjectionIdentityV1: typeof Projection.parseOhProjectionIdentityV1; +export declare const parseOhProjectionLiteralV1: typeof Projection.parseOhProjectionLiteralV1; +export declare const parseOhProjectionQueryV1: typeof Projection.parseOhProjectionQueryV1; +export declare const parseOhProjectionRulePackV1: typeof Projection.parseOhProjectionRulePackV1; +export declare const parseOhProjectionRuleV1: typeof Projection.parseOhProjectionRuleV1; +export declare const parseOhProjectionSnapshotV1: typeof Projection.parseOhProjectionSnapshotV1; +export declare const parseOhProjectionTermV1: typeof Projection.parseOhProjectionTermV1; +export type { OhProjectionAtomV1, OhProjectionDatasetV1, OhProjectionEvaluationOptionsV1, OhProjectionFactSourceV1, OhProjectionFactV1, OhProjectionIdentityV1, OhProjectionInvalidationReasonV1, OhProjectionInvalidationV1, OhProjectionLiteralV1, OhProjectionProofV1, OhProjectionQueryV1, OhProjectionRecordFactOptionsV1, OhProjectionResultRowV1, OhProjectionResultV1, OhProjectionRulePackV1, OhProjectionRuleV1, OhProjectionSnapshotV1, OhProjectionTermV1, } from "./projection"; +//# sourceMappingURL=projection-public.d.ts.map \ No newline at end of file diff --git a/dist/projection-public.d.ts.map b/dist/projection-public.d.ts.map new file mode 100644 index 0000000..e3ea959 --- /dev/null +++ b/dist/projection-public.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"projection-public.d.ts","sourceRoot":"","sources":["../src/projection-public.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,UAAU,MAAM,cAAc,CAAC;AAE3C,eAAO,MAAM,+BAA+B,GAA6C,CAAC;AAC1F,eAAO,MAAM,gCAAgC,wBAA8C,CAAC;AAC5F,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;EAAqC,CAAC;AAC1E,eAAO,MAAM,sCAAsC;;;;;;;EAAoD,CAAC;AACxG,eAAO,MAAM,0BAA0B,qCAAwC,CAAC;AAChF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,yBAAyB,6CAAuC,CAAC;AAC9E,eAAO,MAAM,+BAA+B,mDAA6C,CAAC;AAC1F,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,6BAA6B,iDAA2C,CAAC;AACtF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAE1E,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,+BAA+B,EAC/B,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,gCAAgC,EAChC,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,+BAA+B,EAC/B,uBAAuB,EACvB,oBAAoB,EACpB,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/projection-public.js b/dist/projection-public.js new file mode 100644 index 0000000..89dbd64 --- /dev/null +++ b/dist/projection-public.js @@ -0,0 +1,1990 @@ +// @bun +// src/canonical.ts +import { createHash, randomBytes } from "crypto"; + +class OhValidationError extends Error { + code; + path; + constructor(code, path, message) { + super(`${path}: ${message}`); + this.name = "OhValidationError"; + this.code = code; + this.path = path; + } +} +function isPlainRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasExactKeys(value, keys) { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function assertUnicodeScalarString(value, path) { + 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)) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + index += 1; + } else if (code >= 56320 && code <= 57343) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + } +} +function encodeCanonical(value, path, ancestors) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + assertUnicodeScalarString(value, path); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new OhValidationError("non-json-number", path, "must be finite"); + } + if (Object.is(value, -0)) { + throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null) { + throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`); + } + if (ancestors.has(value)) { + throw new OhValidationError("cycle", path, "contains a cycle"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes"); + } + encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors)); + } + const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)); + if (extraKeys.length > 0) { + throw new OhValidationError("non-json-property", path, "array has non-index properties"); + } + return `[${encoded.join(",")}]`; + } + if (!isPlainRecord(value)) { + throw new OhValidationError("non-plain-object", path, "must be a plain object"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new OhValidationError("non-json-property", path, "object has a symbol property"); + } + const keys = ownKeys; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property"); + } + } + keys.sort(); + const entries = keys.map((key) => { + assertUnicodeScalarString(key, `${path}.`); + return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`; + }); + return `{${entries.join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function canonicalJson(value) { + return encodeCanonical(value, "$", new Set); +} +function parseCanonicalJson(text, maximumBytes = 16 * 1024 * 1024) { + if (utf8ByteLength(text) > maximumBytes) { + throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit"); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new OhValidationError("invalid-json", "$", "is not valid JSON"); + } + if (canonicalJson(value) !== text) { + throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical"); + } + return value; +} +function utf8ByteLength(value) { + return Buffer.byteLength(value, "utf8"); +} +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} +function canonicalSha256(value) { + return sha256Hex(canonicalJson(value)); +} +function parseSha256Hex(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +} +function parseCanonicalInstantV1(value) { + if (typeof value !== "string" || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null; +} +function canonicalNow() { + return new Date().toISOString(); +} +function opaqueId(prefix) { + if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) { + throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code"); + } + return `${prefix}${randomBytes(12).toString("hex")}`; +} +function safeCode(value, maximumLength = 128) { + return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function boundedText(value, maximumBytes = 64 * 1024) { + if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value || utf8ByteLength(value) > maximumBytes) + return null; + try { + assertUnicodeScalarString(value, "$text"); + } catch { + return null; + } + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code <= 8 || code >= 11 && code <= 12 || code >= 14 && code <= 31 || code >= 127 && code <= 159) + return null; + } + return value; +} +function orderedUnique(values, key) { + return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); +} +function sortUnique(values, key) { + const sorted = [...values].sort((left, right) => { + const leftKey = key(left); + const rightKey = key(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + if (!orderedUnique(sorted, key)) { + throw new OhValidationError("duplicate", "$", "contains duplicate canonical values"); + } + return sorted; +} + +// src/graph.ts +var OH_GRAPH_FORMAT_VERSION_V1 = 1; +var OH_GRAPH_LIMITS_V1 = Object.freeze({ + changesPerOperation: 8192, + dependenciesPerRecord: 4096, + recordBytes: 1024 * 1024, + recordsPerSnapshot: 65536 +}); +var OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1 = [ + "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 recordKey(value) { + return typeof value === "string" && value.length <= 512 && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function createKnowledgeGraphRecordV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"]) || input.v !== 1 || !Array.isArray(input.dependencies)) + throw new TypeError("Invalid graph record input."); + const key = recordKey(input.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind); + if (key === null || kind === undefined || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) + throw new TypeError("Invalid graph record identity."); + const dependencies = input.dependencies.map(recordKey); + if (dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) { + throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive."); + } + const valueJson = canonicalJson(input.value); + if (Buffer.byteLength(valueJson, "utf8") > OH_GRAPH_LIMITS_V1.recordBytes) { + throw new RangeError("Graph record value exceeds its canonical byte limit."); + } + const payload = { dependencies, key, kind, v: 1, value: input.value }; + return { ...payload, recordSha256: canonicalSha256(payload) }; +} +function parseKnowledgeGraphRecordV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) + return null; + const recordSha256 = parseSha256Hex(value.recordSha256); + const { recordSha256: _digest, ...input } = value; + try { + const created = createKnowledgeGraphRecordV1(input); + return recordSha256 !== null && created.recordSha256 === recordSha256 ? { ...created, recordSha256 } : null; + } catch { + return null; + } +} +function knowledgeGraphRecordRefV1(record) { + return { + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + sha256: record.recordSha256, + v: 1 + }; +} +function changeKey(change) { + return change.kind === "put" ? change.record.key : change.key; +} +function canonicalKnowledgeGraphChangesV1(changes) { + const normalized = []; + for (const change of changes) { + if (!isPlainRecord(change) || change.v !== 1) + throw new TypeError("Invalid graph change."); + if (change.kind === "put") { + const record = parseKnowledgeGraphRecordV1(change.record); + if (record === null) + throw new TypeError("Invalid graph record in change."); + normalized.push({ kind: "put", record, v: 1 }); + } else if (change.kind === "tombstone") { + const key = recordKey(change.key); + const priorSha256 = parseSha256Hex(change.priorSha256); + if (key === null || priorSha256 === null) + throw new TypeError("Invalid graph tombstone."); + normalized.push({ key, kind: "tombstone", priorSha256, v: 1 }); + } else + throw new TypeError("Unknown graph change kind."); + } + return sortUnique(normalized, changeKey); +} +function graphRevisionSha256V1(input) { + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + const operationId = safeCode(input.operationId); + const parentGraphRevisionSha256 = input.parentGraphRevisionSha256 === null ? null : parseSha256Hex(input.parentGraphRevisionSha256); + const recordsSha256 = parseSha256Hex(input.recordsSha256); + const revision = Number.isSafeInteger(input.revision) && input.revision > 0 ? input.revision : null; + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation || operationId === null || recordsSha256 === null || revision === null || input.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || revision === 1 !== (parentGraphRevisionSha256 === null)) { + throw new TypeError("Invalid graph revision digest input."); + } + return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); +} +function createKnowledgeGraphRevisionV1(input) { + if (input.parent !== null && parseKnowledgeGraphRevisionV1(input.parent) === null) { + throw new TypeError("Invalid parent graph revision."); + } + const operationId = safeCode(input.operationId); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (operationId === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) + throw new TypeError("Invalid graph revision."); + const byKey = new Map((input.parent?.recordRefs ?? []).map((ref) => [ref.key, ref])); + for (const change of changes) { + if (change.kind === "put") { + for (const dependency of change.record.dependencies) { + if (!byKey.has(dependency) && !changes.some((candidate) => candidate.kind === "put" && candidate.record.key === dependency)) { + throw new TypeError(`Missing graph dependency: ${dependency}`); + } + } + byKey.set(change.record.key, knowledgeGraphRecordRefV1(change.record)); + } else { + const prior = byKey.get(change.key); + if (prior === undefined || prior.sha256 !== change.priorSha256) + throw new TypeError("Tombstone prior digest does not match."); + byKey.delete(change.key); + } + } + if (byKey.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph revision exceeds its record snapshot limit."); + } + for (const ref of byKey.values()) { + if (ref.dependencies.some((dependency) => !byKey.has(dependency))) { + throw new TypeError(`Missing graph dependency after revision: ${ref.key}`); + } + } + const recordRefs = [...byKey.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); + const recordsSha256 = canonicalSha256(recordRefs); + const payload = { + changes, + operationId, + parentGraphRevisionSha256: input.parent?.graphRevisionSha256 ?? null, + recordRefs, + recordsSha256, + revision: (input.parent?.revision ?? 0) + 1, + v: 1 + }; + return { ...payload, graphRevisionSha256: graphRevisionSha256V1(payload) }; +} +function parseKnowledgeGraphRevisionV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changes", + "graphRevisionSha256", + "operationId", + "parentGraphRevisionSha256", + "recordRefs", + "recordsSha256", + "revision", + "v" + ]) || value.v !== 1 || !Array.isArray(value.changes) || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + return null; + const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const parentGraphRevisionSha256 = value.parentGraphRevisionSha256 === null ? null : parseSha256Hex(value.parentGraphRevisionSha256); + const operationId = safeCode(value.operationId); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + let changes; + try { + changes = canonicalKnowledgeGraphChangesV1(value.changes); + } catch { + return null; + } + const refs = []; + for (const item of value.recordRefs) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "sha256", "v"]) || item.v !== 1 || !Array.isArray(item.dependencies)) + return null; + const dependencies = item.dependencies.map(recordKey); + const key = recordKey(item.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === item.kind); + const sha256 = parseSha256Hex(item.sha256); + if (key === null || kind === undefined || sha256 === null || dependencies.some((dependency) => dependency === null) || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || !orderedUnique(dependencies, String) || dependencies.includes(key)) + return null; + refs.push({ dependencies, key, kind, sha256, v: 1 }); + } + if (graphRevisionSha256 === null || recordsSha256 === null || operationId === null || revision === null || value.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || !orderedUnique(refs, (ref) => ref.key) || canonicalSha256(refs) !== recordsSha256) + return null; + const keys = new Set(refs.map((ref) => ref.key)); + if (refs.some((ref) => ref.dependencies.some((dependency) => !keys.has(dependency)))) + return null; + const payload = { + changes, + operationId, + parentGraphRevisionSha256, + recordRefs: refs, + recordsSha256, + revision, + v: 1 + }; + try { + return graphRevisionSha256V1(payload) === graphRevisionSha256 ? { ...payload, graphRevisionSha256 } : null; + } catch { + return null; + } +} +function reduceKnowledgeGraphRevisionsV1(revisions) { + if (revisions.length === 0 || revisions.length > 65536) + return null; + const ordered = [...revisions].sort((left, right) => left.revision - right.revision); + let parent = null; + const operationIds = new Set; + for (const candidate of ordered) { + const current = parseKnowledgeGraphRevisionV1(candidate); + if (current === null || current.revision !== (parent?.revision ?? 0) + 1 || current.parentGraphRevisionSha256 !== (parent?.graphRevisionSha256 ?? null) || operationIds.has(current.operationId)) + return null; + try { + const rebuilt = createKnowledgeGraphRevisionV1({ changes: current.changes, operationId: current.operationId, parent }); + if (rebuilt.graphRevisionSha256 !== current.graphRevisionSha256) + return null; + } catch { + return null; + } + operationIds.add(current.operationId); + parent = current; + } + return parent; +} + +// src/ontology.ts +var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; +var OH_CONTRACT_ID_V1 = "oh.ontology.v1"; +var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ + dimensions: 64, + listValues: 256, + qualifiers: 128, + statementBytes: 256 * 1024, + textBytes: 64 * 1024 +}); +var OH_KNOWLEDGE_KERNEL_CONCEPTS_V1 = [ + { code: "entity", description: "A stable identity anchor for something that can be referred to.", label: "Entity" }, + { code: "statement", description: "An immutable proposition with a subject, predicate, object, and qualifiers.", label: "Statement" }, + { code: "assertion", description: "An attributable stance toward a statement.", label: "Assertion" }, + { code: "evidence", description: "A typed account of how an observation bears on an assertion.", label: "Evidence" }, + { code: "context", description: "The scenario and dimensions in which knowledge applies.", label: "Context" }, + { code: "inquiry", description: "A question and its durable investigation trail.", label: "Inquiry" }, + { code: "projection", description: "A reproducible view derived from exact knowledge.", label: "Projection" } +]; +function success(value) { + return { ok: true, value }; +} +function failure(field, code = "invalid-input") { + return { error: { code, field }, ok: false }; +} +function parseOpaqueId(value, prefix) { + return typeof value === "string" && new RegExp(`^${prefix}[a-z0-9]{24}$`, "u").test(value) ? value : null; +} +function parseKnowledgeEntityId(value) { + return parseOpaqueId(value, "kent_"); +} +function parseKnowledgeAssertionId(value) { + return parseOpaqueId(value, "kast_"); +} +function parseKnowledgeEvidenceId(value) { + return parseOpaqueId(value, "kevd_"); +} +function parseKnowledgeInquiryId(value) { + return parseOpaqueId(value, "kinq_"); +} +var OH_KNOWLEDGE_ENTITY_STATES_V1 = ["active", "quarantined", "redirected", "tombstoned"]; +function parseKnowledgeEntityV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "entityId", + "identityOperationId", + "identityRevision", + "redirectEntityId", + "state", + "v" + ]) || value.v !== 1) + return failure("entity"); + const entityId = parseKnowledgeEntityId(value.entityId); + const identityOperationId = safeCode(value.identityOperationId); + const identityRevision = Number.isSafeInteger(value.identityRevision) && value.identityRevision > 0 ? value.identityRevision : null; + const redirectEntityId = value.redirectEntityId === null ? null : parseKnowledgeEntityId(value.redirectEntityId); + const state = OH_KNOWLEDGE_ENTITY_STATES_V1.find((candidate) => candidate === value.state); + return entityId !== null && identityOperationId !== null && identityRevision !== null && (value.redirectEntityId === null || redirectEntityId !== null) && state !== undefined && state === "redirected" === (redirectEntityId !== null) && redirectEntityId !== entityId ? success({ entityId, identityOperationId, identityRevision, redirectEntityId, state, v: 1 }) : failure("entity"); +} +function parseKnowledgeSchemaRefV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["code", "namespace", "revision", "schemaSha256", "v"]) || value.v !== 1) + return failure("schemaRef"); + const code = safeCode(value.code); + const namespace = safeCode(value.namespace); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + const schemaSha256 = parseSha256Hex(value.schemaSha256); + return code !== null && namespace !== null && revision !== null && schemaSha256 !== null ? success({ code, namespace, revision, schemaSha256, v: 1 }) : failure("schemaRef"); +} +var INTEGER = /^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$/u; +var DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/u; +function parseKnowledgeValueInternal(value, depth) { + if (!isPlainRecord(value) || value.v !== 1 || depth > 8) + return null; + switch (value.kind) { + case "entity": { + if (!hasExactKeys(value, ["entityId", "kind", "v"])) + return null; + const entityId = parseKnowledgeEntityId(value.entityId); + return entityId === null ? null : { entityId, kind: "entity", v: 1 }; + } + case "text": { + if (!hasExactKeys(value, ["kind", "language", "text", "v"])) + return null; + const language = typeof value.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(value.language) ? value.language : null; + const text = boundedText(value.text); + return language !== null && text !== null ? { kind: "text", language, text, v: 1 } : null; + } + case "string": { + const parsed = boundedText(value.value); + return hasExactKeys(value, ["kind", "v", "value"]) && parsed !== null ? { kind: "string", v: 1, value: parsed } : null; + } + case "boolean": + return hasExactKeys(value, ["kind", "v", "value"]) && typeof value.value === "boolean" ? { kind: "boolean", v: 1, value: value.value } : null; + case "integer": + case "decimal": { + const valid = typeof value.value === "string" && value.value.length <= 1024 && (value.kind === "integer" ? INTEGER.test(value.value) : DECIMAL.test(value.value) && value.value !== "-0"); + return hasExactKeys(value, ["kind", "v", "value"]) && valid ? { kind: value.kind, v: 1, value: value.value } : null; + } + case "uri": { + if (!hasExactKeys(value, ["kind", "uri", "v"]) || typeof value.uri !== "string" || value.uri.length > 4096) + return null; + try { + const url = new URL(value.uri); + return url.href === value.uri && url.username === "" && url.password === "" && !["data:", "file:", "javascript:"].includes(url.protocol) ? { kind: "uri", uri: value.uri, v: 1 } : null; + } catch { + return null; + } + } + case "list": + case "set": { + if (!hasExactKeys(value, ["kind", "v", "values"]) || !Array.isArray(value.values) || value.values.length > OH_KNOWLEDGE_LIMITS_V1.listValues) + return null; + const values = []; + for (const item of value.values) { + const parsed = parseKnowledgeValueInternal(item, depth + 1); + if (parsed === null) + return null; + values.push(parsed); + } + if (value.kind === "set" && !orderedUnique(values, canonicalJson)) + return null; + return { kind: value.kind, v: 1, values }; + } + case "extension": { + if (!hasExactKeys(value, ["canonicalizerSha256", "canonicalValue", "kind", "mediaType", "schema", "v", "valueSha256"])) + return null; + const canonicalizerSha256 = parseSha256Hex(value.canonicalizerSha256); + const canonicalValue = boundedText(value.canonicalValue, 64 * 1024); + const mediaType = typeof value.mediaType === "string" && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/u.test(value.mediaType) ? value.mediaType : null; + const schema = parseKnowledgeSchemaRefV1(value.schema); + const valueSha256 = parseSha256Hex(value.valueSha256); + return canonicalizerSha256 !== null && canonicalValue !== null && mediaType !== null && schema.ok && valueSha256 !== null ? { canonicalizerSha256, canonicalValue, kind: "extension", mediaType, schema: schema.value, v: 1, valueSha256 } : null; + } + default: + return null; + } +} +function parseKnowledgeValueV1(value) { + const parsed = parseKnowledgeValueInternal(value, 0); + return parsed === null ? failure("value") : success(parsed); +} +function verifyKnowledgeValueV1(value) { + const parsed = parseKnowledgeValueV1(value); + if (!parsed.ok) + return parsed; + if (parsed.value.kind === "extension" && sha256Hex(parsed.value.canonicalValue) !== parsed.value.valueSha256) { + return failure("valueSha256", "digest-mismatch"); + } + if (parsed.value.kind === "list" || parsed.value.kind === "set") { + for (const child of parsed.value.values) { + const verified = verifyKnowledgeValueV1(child); + if (!verified.ok) + return verified; + } + } + return success(parsed.value); +} +function parseDimension(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["predicate", "v", "value"]) || value.v !== 1) + return null; + const predicate = parseKnowledgeSchemaRefV1(value.predicate); + const parsedValue = parseKnowledgeValueV1(value.value); + return predicate.ok && parsedValue.ok ? { predicate: predicate.value, v: 1, value: parsedValue.value } : null; +} +function createKnowledgeContextV1(input) { + if (!isPlainRecord(input) || input.v !== 1 || !Array.isArray(input.dimensions) || input.dimensions.length > OH_KNOWLEDGE_LIMITS_V1.dimensions || !["actual", "counterfactual", "hypothetical", "planned"].includes(input.scenario)) + return failure("context"); + const dimensions = []; + for (const item of input.dimensions) { + const parsed = parseDimension(item); + if (parsed === null) + return failure("dimensions"); + const verified = verifyKnowledgeValueV1(parsed.value); + if (!verified.ok) + return verified; + dimensions.push(parsed); + } + let canonicalDimensions; + try { + canonicalDimensions = sortUnique(dimensions, canonicalJson); + } catch { + return failure("dimensions", "noncanonical-input"); + } + const payload = { dimensions: canonicalDimensions, scenario: input.scenario, v: 1 }; + return success({ ...payload, contextSha256: canonicalSha256(payload) }); +} +function parseKnowledgeContextV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["contextSha256", "dimensions", "scenario", "v"])) + return failure("context"); + const digest = parseSha256Hex(value.contextSha256); + if (digest === null) + return failure("contextSha256"); + const created = createKnowledgeContextV1({ dimensions: value.dimensions, scenario: value.scenario, v: value.v }); + return created.ok && created.value.contextSha256 === digest && canonicalJson(created.value.dimensions) === canonicalJson(value.dimensions) ? success({ ...created.value, contextSha256: digest }) : failure("contextSha256", "digest-mismatch"); +} +function createKnowledgeStatementV1(input) { + const object = parseKnowledgeValueV1(input.object); + const predicate = parseKnowledgeSchemaRefV1(input.predicate); + const subject = parseKnowledgeEntityId(input.subject); + if (input.v !== 1 || !object.ok || !predicate.ok || subject === null || !Array.isArray(input.qualifiers) || input.qualifiers.length > OH_KNOWLEDGE_LIMITS_V1.qualifiers) + return failure("statement"); + const verifiedObject = verifyKnowledgeValueV1(object.value); + if (!verifiedObject.ok) + return verifiedObject; + const qualifiers = []; + for (const item of input.qualifiers) { + const parsed = parseDimension(item); + if (parsed === null) + return failure("qualifiers"); + const verified = verifyKnowledgeValueV1(parsed.value); + if (!verified.ok) + return verified; + qualifiers.push(parsed); + } + let canonicalQualifiers; + try { + canonicalQualifiers = sortUnique(qualifiers, canonicalJson); + } catch { + return failure("qualifiers", "noncanonical-input"); + } + const payload = { object: object.value, predicate: predicate.value, qualifiers: canonicalQualifiers, subject, v: 1 }; + if (Buffer.byteLength(canonicalJson(payload), "utf8") > OH_KNOWLEDGE_LIMITS_V1.statementBytes) + return failure("statement", "limit-exceeded"); + return success({ ...payload, statementSha256: canonicalSha256(payload) }); +} +function parseKnowledgeStatementV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["object", "predicate", "qualifiers", "statementSha256", "subject", "v"])) + return failure("statement"); + const digest = parseSha256Hex(value.statementSha256); + const created = createKnowledgeStatementV1(value); + return digest !== null && created.ok && created.value.statementSha256 === digest && canonicalJson(created.value.qualifiers) === canonicalJson(value.qualifiers) ? success({ ...created.value, statementSha256: digest }) : failure("statementSha256", "digest-mismatch"); +} +function parseKnowledgeAgentRefV1(value) { + if (!isPlainRecord(value) || value.v !== 1) + return null; + if (value.kind === "entity" && hasExactKeys(value, ["entityId", "kind", "v"])) { + const entityId = parseKnowledgeEntityId(value.entityId); + return entityId === null ? null : { entityId, kind: "entity", v: 1 }; + } + if (value.kind === "model" && hasExactKeys(value, ["kind", "model", "receiptSha256", "v"])) { + const model = parseKnowledgeSchemaRefV1(value.model); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + return model.ok && receiptSha256 !== null ? { kind: "model", model: model.value, receiptSha256, v: 1 } : null; + } + if (value.kind === "system" && hasExactKeys(value, ["authority", "kind", "receiptSha256", "v"])) { + const authority = parseKnowledgeSchemaRefV1(value.authority); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + return authority.ok && receiptSha256 !== null ? { authority: authority.value, kind: "system", receiptSha256, v: 1 } : null; + } + return null; +} +function parseDigestArray(value, maximum = 2048) { + if (!Array.isArray(value) || value.length > maximum) + return null; + const digests = value.map(parseSha256Hex); + return digests.every((digest) => digest !== null) && orderedUnique(digests, String) ? digests : null; +} +var OH_KNOWLEDGE_ACTIVITY_KINDS_V1 = [ + "extraction", + "human-entry", + "human-review", + "import", + "model-proposal", + "normalization", + "publication", + "resolution", + "transformation" +]; +function parseActivityInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actor", + "inputSha256s", + "kind", + "occurredAt", + "outputSha256s", + "policySha256", + "tool", + "v" + ]) || value.v !== 1) + return null; + const actor = parseKnowledgeAgentRefV1(value.actor); + const inputSha256s = parseDigestArray(value.inputSha256s); + const kind = OH_KNOWLEDGE_ACTIVITY_KINDS_V1.find((candidate) => candidate === value.kind); + const occurredAt = parseCanonicalInstantV1(value.occurredAt); + const outputSha256s = parseDigestArray(value.outputSha256s); + const policySha256 = parseSha256Hex(value.policySha256); + const tool = value.tool === null ? null : parseKnowledgeSchemaRefV1(value.tool); + const parsedTool = tool === null ? null : tool.ok ? tool.value : null; + return actor !== null && inputSha256s !== null && kind !== undefined && occurredAt !== null && outputSha256s !== null && policySha256 !== null && (value.tool === null || parsedTool !== null) ? { actor, inputSha256s, kind, occurredAt, outputSha256s, policySha256, tool: parsedTool, v: 1 } : null; +} +function createKnowledgeActivityV1(input) { + const parsed = parseActivityInput(input); + return parsed === null ? failure("activity") : success({ ...parsed, activitySha256: canonicalSha256(parsed) }); +} +function parseKnowledgeActivityV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "activitySha256")) + return failure("activity"); + const activitySha256 = parseSha256Hex(value.activitySha256); + const { activitySha256: _digest, ...input } = value; + const parsed = parseActivityInput(input); + return activitySha256 !== null && parsed !== null && canonicalSha256(parsed) === activitySha256 ? success({ ...parsed, activitySha256 }) : failure("activitySha256", "digest-mismatch"); +} +var OH_KNOWLEDGE_ASSERTION_STANCES_V1 = ["questions", "refutes", "reports", "supports", "undetermined"]; +var OH_KNOWLEDGE_ASSERTION_STATES_V1 = [ + "accepted-for-purpose", + "disputed", + "proposed", + "reviewed", + "superseded", + "withdrawn" +]; +function parseStringCodes(value, maximum) { + if (!Array.isArray(value) || value.length > maximum) + return null; + const codes = value.map((item) => safeCode(item)); + return codes.every((code) => code !== null) && orderedUnique(codes, String) ? codes : null; +} +function parseAssertionInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "acceptedPurposes", + "assertionId", + "assertor", + "confidence", + "contextSha256", + "provenanceActivitySha256", + "reviewActivitySha256", + "stance", + "state", + "statementSha256", + "v" + ]) || value.v !== 1) + return null; + const acceptedPurposes = parseStringCodes(value.acceptedPurposes, 32); + const assertionId = parseKnowledgeAssertionId(value.assertionId); + const assertor = parseKnowledgeAgentRefV1(value.assertor); + const confidence = value.confidence === null ? null : parseKnowledgeSchemaRefV1(value.confidence); + const parsedConfidence = confidence === null ? null : confidence.ok ? confidence.value : null; + const contextSha256 = value.contextSha256 === null ? null : parseSha256Hex(value.contextSha256); + const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); + const reviewActivitySha256 = value.reviewActivitySha256 === null ? null : parseSha256Hex(value.reviewActivitySha256); + const stance = OH_KNOWLEDGE_ASSERTION_STANCES_V1.find((candidate) => candidate === value.stance); + const state = OH_KNOWLEDGE_ASSERTION_STATES_V1.find((candidate) => candidate === value.state); + const statementSha256 = parseSha256Hex(value.statementSha256); + if (acceptedPurposes === null || assertionId === null || assertor === null || value.confidence !== null && parsedConfidence === null || value.contextSha256 !== null && contextSha256 === null || provenanceActivitySha256 === null || value.reviewActivitySha256 !== null && reviewActivitySha256 === null || stance === undefined || state === undefined || statementSha256 === null) + return null; + if (assertor.kind === "model" && (state !== "proposed" || acceptedPurposes.length !== 0 || reviewActivitySha256 !== null)) + return null; + if (state === "accepted-for-purpose" !== acceptedPurposes.length > 0 || state !== "proposed" && reviewActivitySha256 === null) + return null; + return { + acceptedPurposes, + assertionId, + assertor, + confidence: parsedConfidence, + contextSha256, + provenanceActivitySha256, + reviewActivitySha256, + stance, + state, + statementSha256, + v: 1 + }; +} +function createKnowledgeAssertionV1(input) { + const parsed = parseAssertionInput(input); + return parsed === null ? failure("assertion") : success({ ...parsed, assertionSha256: canonicalSha256(parsed) }); +} +function parseKnowledgeAssertionV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "assertionSha256")) + return failure("assertion"); + const assertionSha256 = parseSha256Hex(value.assertionSha256); + const { assertionSha256: _digest, ...input } = value; + const parsed = parseAssertionInput(input); + return assertionSha256 !== null && parsed !== null && canonicalSha256(parsed) === assertionSha256 ? success({ ...parsed, assertionSha256 }) : failure("assertionSha256", "digest-mismatch"); +} +var OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1 = [ + "background", + "contradicts", + "corroborates", + "direct-observation", + "method", + "quotation", + "registry-record", + "supports" +]; +function parseEvidenceInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "assertionSha256", + "bearing", + "disclosure", + "evidenceId", + "observationSha256", + "provenanceActivitySha256", + "selector", + "sourceEntityId", + "v" + ]) || value.v !== 1) + return null; + const assertionSha256 = parseSha256Hex(value.assertionSha256); + const bearing = OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1.find((candidate) => candidate === value.bearing); + const evidenceId = parseKnowledgeEvidenceId(value.evidenceId); + const observationSha256 = value.observationSha256 === null ? null : parseSha256Hex(value.observationSha256); + const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); + const selector = value.selector === null ? null : boundedText(value.selector, 8192); + const sourceEntityId = value.sourceEntityId === null ? null : parseKnowledgeEntityId(value.sourceEntityId); + return assertionSha256 !== null && bearing !== undefined && (value.disclosure === "private" || value.disclosure === "public" || value.disclosure === "shared") && evidenceId !== null && (value.observationSha256 === null || observationSha256 !== null) && provenanceActivitySha256 !== null && (value.selector === null || selector !== null) && (value.sourceEntityId === null || sourceEntityId !== null) && (observationSha256 !== null || sourceEntityId !== null) ? { + assertionSha256, + bearing, + disclosure: value.disclosure, + evidenceId, + observationSha256, + provenanceActivitySha256, + selector, + sourceEntityId, + v: 1 + } : null; +} +function createKnowledgeEvidenceLinkV1(input) { + const parsed = parseEvidenceInput(input); + return parsed === null ? failure("evidence") : success({ ...parsed, evidenceSha256: canonicalSha256(parsed) }); +} +function parseKnowledgeEvidenceLinkV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "evidenceSha256")) + return failure("evidence"); + const evidenceSha256 = parseSha256Hex(value.evidenceSha256); + const { evidenceSha256: _digest, ...input } = value; + const parsed = parseEvidenceInput(input); + return evidenceSha256 !== null && parsed !== null && canonicalSha256(parsed) === evidenceSha256 ? success({ ...parsed, evidenceSha256 }) : failure("evidenceSha256", "digest-mismatch"); +} +function createKnowledgeInquiryV1(input) { + const answerForm = safeCode(input.answerForm); + const authorEntityId = parseKnowledgeEntityId(input.authorEntityId); + const contextSha256 = input.contextSha256 === null ? null : parseSha256Hex(input.contextSha256); + const createdAt = parseCanonicalInstantV1(input.createdAt); + const inquiryId = parseKnowledgeInquiryId(input.inquiryId); + const language = typeof input.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(input.language) ? input.language : null; + const parents = Array.isArray(input.parentInquiryIds) ? input.parentInquiryIds.map(parseKnowledgeInquiryId) : null; + const question = boundedText(input.question, 16384); + if (input.v !== 1 || answerForm === null || authorEntityId === null || input.contextSha256 !== null && contextSha256 === null || createdAt === null || inquiryId === null || language === null || parents === null || parents.some((item) => item === null) || !orderedUnique(parents, String) || !["private", "public", "shared"].includes(input.privacy) || question === null || !["abandoned", "open", "paused", "resolved"].includes(input.status)) + return failure("inquiry"); + const payload = { + answerForm, + authorEntityId, + contextSha256, + createdAt, + inquiryId, + language, + parentInquiryIds: parents, + privacy: input.privacy, + question, + status: input.status, + v: 1 + }; + return success({ ...payload, inquirySha256: canonicalSha256(payload) }); +} +function parseKnowledgeInquiryV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "inquirySha256")) + return failure("inquiry"); + const digest = parseSha256Hex(value.inquirySha256); + const { inquirySha256: _digest, ...input } = value; + const created = createKnowledgeInquiryV1(input); + return digest !== null && created.ok && created.value.inquirySha256 === digest ? success({ ...created.value, inquirySha256: digest }) : failure("inquirySha256", "digest-mismatch"); +} + +// src/schema.ts +var OH_SCHEMA_FORMAT_VERSION_V1 = 1; +var OH_SCHEMA_KINDS_V1 = ["concept", "mapping", "predicate", "shape", "unit", "vocabulary"]; +function parseLocalizedTexts(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > 128) + return null; + const output = []; + for (const item of value) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["language", "text", "v"]) || item.v !== 1) + return null; + const language = typeof item.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(item.language) ? item.language : null; + const text = boundedText(item.text, 16384); + if (language === null || text === null) + return null; + output.push({ language, text, v: 1 }); + } + return orderedUnique(output, canonicalJson) ? output : null; +} +function parseSchemaInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "body", + "code", + "compatibility", + "description", + "kind", + "labels", + "namespace", + "previousSchemaSha256", + "revision", + "v" + ]) || value.v !== 1 || !isPlainRecord(value.body)) + return null; + try { + canonicalJson(value.body); + } catch { + return null; + } + const code = safeCode(value.code); + const namespace = safeCode(value.namespace); + const kind = OH_SCHEMA_KINDS_V1.find((candidate) => candidate === value.kind); + const labels = parseLocalizedTexts(value.labels); + const description = parseLocalizedTexts(value.description); + const previousSchemaSha256 = value.previousSchemaSha256 === null ? null : parseSha256Hex(value.previousSchemaSha256); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + const compatibility = value.compatibility === "additive" || value.compatibility === "breaking" ? value.compatibility : null; + return code !== null && namespace !== null && kind !== undefined && labels !== null && description !== null && (value.previousSchemaSha256 === null || previousSchemaSha256 !== null) && revision !== null && compatibility !== null && revision === 1 === (previousSchemaSha256 === null) && (revision !== 1 || compatibility === "additive") ? { + body: value.body, + code, + compatibility, + description, + kind, + labels, + namespace, + previousSchemaSha256, + revision, + v: 1 + } : null; +} +function createKnowledgeSchemaRevisionV1(input) { + const parsed = parseSchemaInput(input); + if (parsed === null) + throw new TypeError("Invalid schema revision input."); + return { ...parsed, schemaSha256: canonicalSha256(parsed) }; +} +function parseKnowledgeSchemaRevisionV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "schemaSha256")) + return null; + const schemaSha256 = parseSha256Hex(value.schemaSha256); + const { schemaSha256: _digest, ...input } = value; + const parsed = parseSchemaInput(input); + return schemaSha256 !== null && parsed !== null && canonicalSha256(parsed) === schemaSha256 ? { ...parsed, schemaSha256 } : null; +} +function knowledgeSchemaRefV1(schema) { + return { + code: schema.code, + namespace: schema.namespace, + revision: schema.revision, + schemaSha256: schema.schemaSha256, + v: 1 + }; +} +function additiveBodyRetainsPrior(prior, next) { + return Object.entries(prior).every(([key, value]) => Object.hasOwn(next, key) && canonicalJson(next[key]) === canonicalJson(value)); +} +function verifyKnowledgeSchemaEvolutionV1(prior, next) { + if (parseKnowledgeSchemaRevisionV1(prior) === null || parseKnowledgeSchemaRevisionV1(next) === null) { + return { ok: false, reason: "invalid-schema" }; + } + if (prior.namespace !== next.namespace || prior.code !== next.code || prior.kind !== next.kind) { + return { ok: false, reason: "identity-changed" }; + } + if (next.revision !== prior.revision + 1 || next.previousSchemaSha256 !== prior.schemaSha256) { + return { ok: false, reason: "chain-broken" }; + } + if (next.compatibility === "additive" && !additiveBodyRetainsPrior(prior.body, next.body)) { + return { ok: false, reason: "false-additive-claim" }; + } + return { ok: true }; +} +function createKnowledgeVocabularyRevisionV1(input) { + const namespace = safeCode(input.namespace); + if (namespace === null || input.v !== 1 || !Number.isSafeInteger(input.revision) || input.revision < 1 || !Array.isArray(input.schemaRefs) || input.schemaRefs.length > 65536) { + throw new TypeError("Invalid vocabulary revision input."); + } + const refs = []; + for (const candidate of input.schemaRefs) { + const parsed = parseKnowledgeSchemaRefV1(candidate); + if (!parsed.ok || parsed.value.namespace !== namespace) + throw new TypeError("Invalid vocabulary schema reference."); + refs.push(parsed.value); + } + if (!orderedUnique(refs, canonicalJson)) + throw new TypeError("Vocabulary schema references must be ordered and unique."); + const payload = { namespace, revision: input.revision, schemaRefs: refs, v: 1 }; + return { ...payload, vocabularySha256: canonicalSha256(payload) }; +} +function parseKnowledgeVocabularyRevisionV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["namespace", "revision", "schemaRefs", "v", "vocabularySha256"])) + return null; + const digest = parseSha256Hex(value.vocabularySha256); + try { + const created = createKnowledgeVocabularyRevisionV1({ + namespace: value.namespace, + revision: value.revision, + schemaRefs: value.schemaRefs, + v: value.v + }); + return digest !== null && created.vocabularySha256 === digest ? { ...created, vocabularySha256: digest } : null; + } catch { + return null; + } +} + +// src/contract.ts +var manifestPayload = Object.freeze({ + contractId: OH_CONTRACT_ID_V1, + graphFormatVersion: OH_GRAPH_FORMAT_VERSION_V1, + ontologyVersion: OH_ONTOLOGY_VERSION_V1, + recordKinds: OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + schemaFormatVersion: OH_SCHEMA_FORMAT_VERSION_V1, + v: 1 +}); +var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ + ...manifestPayload, + contractSha256: canonicalSha256(manifestPayload) +}); +function parseOhContractManifestV1(value) { + try { + return canonicalJson(value) === canonicalJson(OH_CONTRACT_MANIFEST_V1) ? OH_CONTRACT_MANIFEST_V1 : null; + } catch { + return null; + } +} + +class OhRecordCodecRegistry { + #codecs = new Map; + register(codec) { + if (this.#codecs.has(codec.kind)) + throw new TypeError(`A codec is already registered for ${codec.kind}.`); + this.#codecs.set(codec.kind, codec); + return this; + } + parse(kind, value) { + const codec = this.#codecs.get(kind); + if (codec !== undefined) + return codec.parse(value); + try { + canonicalJson(value); + return value; + } catch { + return null; + } + } + has(kind) { + return this.#codecs.has(kind); + } +} + +// src/projection.ts +var OH_PROJECTION_FORMAT_VERSION_V1 = 1; +var OH_PROJECTION_SEMANTICS_V1 = "oh.projection.positive-datalog.v1"; +var OH_PROJECTION_INTERNAL_ENGINE_V1 = "oh.naive.positive.v1"; +var OH_PROJECTION_LIMITS_V1 = Object.freeze({ + arity: 32, + atomBytes: 16 * 1024, + derivedTuples: 262144, + facts: 262144, + literalsPerRule: 64, + proofDepth: 128, + proofNodes: 4096, + queryLiterals: 64, + queryMatches: 262144, + queryResults: 65536, + relations: 4096, + rounds: 1024, + rules: 1024, + sourcesPerFact: 64, + variables: 256 +}); +var recordFactExtractorPayloadV1 = { + factPackId: "oh.record-facts", + factPackRevision: 1, + relations: ["oh.dependency", "oh.record"], + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 +}; +var OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1 = Object.freeze({ + ...recordFactExtractorPayloadV1, + extractorSha256: canonicalSha256(recordFactExtractorPayloadV1) +}); +function nonnegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} +function positiveInteger(value, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : null; +} +function projectionName(value, maximumLength = 128) { + return safeCode(value, maximumLength); +} +function compareCanonical(left, right) { + const leftKey = canonicalJson(left); + const rightKey = canonicalJson(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; +} +function compareProjectionFacts(left, right) { + return compareCanonical([left.relation, left.tuple], [right.relation, right.tuple]); +} +var INVALID_PROJECTION_ATOM = Symbol("invalid-projection-atom"); +function atom(value) { + if (value !== null && typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string") { + return INVALID_PROJECTION_ATOM; + } + try { + const encoded = canonicalJson(value); + return utf8ByteLength(encoded) <= OH_PROJECTION_LIMITS_V1.atomBytes ? value : INVALID_PROJECTION_ATOM; + } catch { + return INVALID_PROJECTION_ATOM; + } +} +function tuple(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > OH_PROJECTION_LIMITS_V1.arity) + return null; + const parsed = value.map(atom); + return parsed.some((item) => item === INVALID_PROJECTION_ATOM) ? null : parsed; +} +function parseRecordRef(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["dependencies", "key", "kind", "sha256", "v"]) || value.v !== 1 || !Array.isArray(value.dependencies)) + return null; + const key = safeCode(value.key, 512); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === value.kind); + const sha256 = parseSha256Hex(value.sha256); + const dependencies = value.dependencies.map((dependency) => safeCode(dependency, 512)); + if (key === null || kind === undefined || sha256 === null || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) + return null; + return { dependencies, key, kind, sha256, v: 1 }; +} +function createOhProjectionSnapshotV1(input) { + const spaceId = projectionName(input.spaceId); + const generation = nonnegativeInteger(input.head.generation); + const sequence = nonnegativeInteger(input.head.sequence); + const operationSha256 = input.head.operationSha256 === null ? null : parseSha256Hex(input.head.operationSha256); + const graphRevisionSha256 = input.head.graphRevisionSha256 === null ? null : parseSha256Hex(input.head.graphRevisionSha256); + const declaredRecordsSha256 = parseSha256Hex(input.head.recordsSha256); + if (spaceId === null || generation === null || sequence === null || generation !== sequence || input.head.operationSha256 !== null && operationSha256 === null || input.head.graphRevisionSha256 !== null && graphRevisionSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || declaredRecordsSha256 === null || input.records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid projection snapshot head."); + } + const records = input.records.map(parseKnowledgeGraphRecordV1); + if (records.some((record) => record === null)) + throw new TypeError("Invalid record in projection snapshot."); + const recordRefs = sortUnique(records.map(knowledgeGraphRecordRefV1), (reference) => reference.key); + const recordsSha256 = canonicalSha256(recordRefs); + if (recordsSha256 !== declaredRecordsSha256) { + throw new TypeError("Projection snapshot records do not reproduce the declared head."); + } + const keys = new Set(recordRefs.map((reference) => reference.key)); + if (recordRefs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) { + throw new TypeError("Projection snapshot has a missing record dependency."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return { ...payload, snapshotSha256: canonicalSha256(payload) }; +} +function parseOhProjectionSnapshotV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "generation", + "graphRevisionSha256", + "operationSha256", + "recordRefs", + "recordsSha256", + "sequence", + "snapshotSha256", + "spaceId", + "v" + ]) || value.v !== 1 || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const generation = nonnegativeInteger(value.generation); + const sequence = nonnegativeInteger(value.sequence); + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + const spaceId = projectionName(value.spaceId); + const recordRefs = value.recordRefs.map(parseRecordRef); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || generation === null || sequence === null || generation !== sequence || spaceId === null || recordsSha256 === null || snapshotSha256 === null || value.graphRevisionSha256 !== null && graphRevisionSha256 === null || value.operationSha256 !== null && operationSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || recordRefs.some((reference) => reference === null)) + return null; + const refs = recordRefs; + if (!orderedUnique(refs, (reference) => reference.key) || canonicalSha256(refs) !== recordsSha256) + return null; + const keys = new Set(refs.map((reference) => reference.key)); + if (refs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) + return null; + const payload = { + contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs: refs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return canonicalSha256(payload) === snapshotSha256 ? { ...payload, snapshotSha256 } : null; +} +function createOhProjectionFactV1(input) { + const relation = projectionName(input.relation); + const parsedTuple = tuple(input.tuple); + if (relation === null || parsedTuple === null || input.sources.length < 1 || input.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) { + throw new TypeError("Invalid projection fact."); + } + const sources = input.sources.map((source) => { + if (!isPlainRecord(source) || !hasExactKeys(source, ["key", "recordSha256", "v"]) || source.v !== 1) { + throw new TypeError("Invalid projection fact source."); + } + const key = safeCode(source.key, 512); + const recordSha256 = parseSha256Hex(source.recordSha256); + if (key === null || recordSha256 === null) + throw new TypeError("Invalid projection fact source."); + return { key, recordSha256, v: 1 }; + }).sort(compareCanonical); + if (!orderedUnique(sources, (source) => source.key)) { + throw new TypeError("Projection fact sources must have unique record keys."); + } + const payload = { relation, sources, tuple: parsedTuple, v: 1 }; + return { ...payload, factSha256: canonicalSha256(payload) }; +} +function parseOhProjectionFactV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["factSha256", "relation", "sources", "tuple", "v"]) || value.v !== 1 || !Array.isArray(value.sources) || !Array.isArray(value.tuple)) + return null; + const factSha256 = parseSha256Hex(value.factSha256); + try { + const fact = createOhProjectionFactV1({ + relation: value.relation, + sources: value.sources, + tuple: value.tuple + }); + return factSha256 !== null && fact.factSha256 === factSha256 ? fact : null; + } catch { + return null; + } +} +function mergeProjectionFacts(facts) { + const grouped = new Map; + for (const fact of facts) { + const identity = canonicalJson([fact.relation, fact.tuple]); + let group = grouped.get(identity); + if (group === undefined) { + group = { relation: fact.relation, sources: new Map, tuple: fact.tuple }; + grouped.set(identity, group); + } + for (const source of fact.sources) { + const existing = group.sources.get(source.key); + if (existing !== undefined && existing.recordSha256 !== source.recordSha256) { + throw new TypeError("One fact source key is bound to multiple record digests."); + } + group.sources.set(source.key, source); + } + } + return [...grouped.values()].map((group) => createOhProjectionFactV1({ + relation: group.relation, + sources: [...group.sources.values()], + tuple: group.tuple + })).sort(compareProjectionFacts); +} +function createOhProjectionDatasetV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const extractorSha256 = parseSha256Hex(input.extractorSha256); + const factPackId = projectionName(input.factPackId); + const factPackRevision = positiveInteger(input.factPackRevision); + if (snapshot === null || extractorSha256 === null || factPackId === null || factPackRevision === null || input.facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new TypeError("Invalid projection dataset."); + const parsedFacts = input.facts.map(parseOhProjectionFactV1); + if (parsedFacts.some((fact) => fact === null)) + throw new TypeError("Invalid fact in projection dataset."); + const facts = mergeProjectionFacts(parsedFacts); + if (facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new RangeError("Projection dataset has too many facts."); + const refs = new Map(snapshot.recordRefs.map((reference) => [reference.key, reference.sha256])); + for (const fact of facts) { + for (const source of fact.sources) { + if (refs.get(source.key) !== source.recordSha256) { + throw new TypeError("Projection fact source is not present at the exact input snapshot."); + } + } + } + const factPackPayload = { + extractorSha256, + factPackId, + factPackRevision, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + const factPackSha256 = canonicalSha256(factPackPayload); + const factsSha256 = canonicalSha256(facts); + const payload = { + extractorSha256, + factPackId, + factPackRevision, + factPackSha256, + facts, + factsSha256, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, datasetSha256: canonicalSha256(payload) }; +} +function parseOhProjectionDatasetV1(value, snapshot) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "datasetSha256", + "extractorSha256", + "factPackId", + "factPackRevision", + "factPackSha256", + "facts", + "factsSha256", + "snapshotSha256", + "v" + ]) || value.v !== 1 || !Array.isArray(value.facts)) + return null; + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const declaredFactPackSha256 = parseSha256Hex(value.factPackSha256); + const declaredFactsSha256 = parseSha256Hex(value.factsSha256); + try { + const dataset = createOhProjectionDatasetV1({ + extractorSha256: value.extractorSha256, + factPackId: value.factPackId, + factPackRevision: value.factPackRevision, + facts: value.facts, + snapshot + }); + return datasetSha256 !== null && declaredFactPackSha256 === dataset.factPackSha256 && declaredFactsSha256 === dataset.factsSha256 && value.snapshotSha256 === dataset.snapshotSha256 && dataset.datasetSha256 === datasetSha256 ? dataset : null; + } catch { + return null; + } +} +function ohProjectionVariableV1(name) { + const parsed = projectionName(name); + if (parsed === null) + throw new TypeError("Invalid projection variable name."); + return { kind: "variable", name: parsed, v: 1 }; +} +function ohProjectionConstantV1(value) { + const parsed = atom(value); + if (parsed === INVALID_PROJECTION_ATOM) + throw new TypeError("Invalid projection constant."); + return { kind: "constant", v: 1, value: parsed }; +} +function createOhProjectionLiteralV1(input) { + const relation = projectionName(input.relation); + if (relation === null || input.terms.length < 1 || input.terms.length > OH_PROJECTION_LIMITS_V1.arity) { + throw new TypeError("Invalid projection literal."); + } + const terms = input.terms.map((term) => parseOhProjectionTermV1(term)); + if (terms.some((term) => term === null)) + throw new TypeError("Invalid term in projection literal."); + return { relation, terms, v: 1 }; +} +function parseOhProjectionTermV1(value) { + if (!isPlainRecord(value) || value.v !== 1) + return null; + if (value.kind === "variable" && hasExactKeys(value, ["kind", "name", "v"])) { + const name = projectionName(value.name); + return name === null ? null : { kind: "variable", name, v: 1 }; + } + if (value.kind === "constant" && hasExactKeys(value, ["kind", "v", "value"])) { + const parsed = atom(value.value); + return parsed === INVALID_PROJECTION_ATOM ? null : { kind: "constant", v: 1, value: parsed }; + } + return null; +} +function parseOhProjectionLiteralV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["relation", "terms", "v"]) || value.v !== 1 || !Array.isArray(value.terms)) + return null; + try { + return createOhProjectionLiteralV1({ + relation: value.relation, + terms: value.terms + }); + } catch { + return null; + } +} +function literalVariables(literal) { + return literal.terms.flatMap((term) => term.kind === "variable" ? [term.name] : []); +} +function createOhProjectionRuleV1(input) { + const ruleId = projectionName(input.ruleId); + const head = parseOhProjectionLiteralV1(input.head); + if (ruleId === null || head === null || input.body.length < 1 || input.body.length > OH_PROJECTION_LIMITS_V1.literalsPerRule) + throw new TypeError("Invalid projection rule."); + const body = input.body.map(parseOhProjectionLiteralV1); + if (body.some((literal) => literal === null)) + throw new TypeError("Invalid body literal in projection rule."); + const bound = new Set(body.flatMap(literalVariables)); + if (literalVariables(head).some((variable) => !bound.has(variable)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projection rule head variable must be bound in its body."); + } + const payload = { body, head, ruleId, v: 1 }; + return { ...payload, ruleSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRuleV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["body", "head", "ruleId", "ruleSha256", "v"]) || value.v !== 1 || !Array.isArray(value.body)) + return null; + const ruleSha256 = parseSha256Hex(value.ruleSha256); + try { + const rule = createOhProjectionRuleV1({ + body: value.body, + head: value.head, + ruleId: value.ruleId + }); + return ruleSha256 !== null && rule.ruleSha256 === ruleSha256 ? rule : null; + } catch { + return null; + } +} +function createOhProjectionRulePackV1(input) { + const rulePackId = projectionName(input.rulePackId); + const rulePackRevision = positiveInteger(input.rulePackRevision); + if (rulePackId === null || rulePackRevision === null || input.rules.length < 1 || input.rules.length > OH_PROJECTION_LIMITS_V1.rules) + throw new TypeError("Invalid projection rule pack."); + const parsedRules = input.rules.map(parseOhProjectionRuleV1); + if (parsedRules.some((rule) => rule === null)) + throw new TypeError("Invalid rule in projection rule pack."); + const rules = sortUnique(parsedRules, (rule) => rule.ruleId); + const rulesSha256 = canonicalSha256(rules); + const payload = { + rulePackId, + rulePackRevision, + rules, + rulesSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + return { ...payload, rulePackSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRulePackV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "rulePackId", + "rulePackRevision", + "rulePackSha256", + "rules", + "rulesSha256", + "semantics", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1 || !Array.isArray(value.rules)) + return null; + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const rulesSha256 = parseSha256Hex(value.rulesSha256); + try { + const pack = createOhProjectionRulePackV1({ + rulePackId: value.rulePackId, + rulePackRevision: value.rulePackRevision, + rules: value.rules + }); + return rulePackSha256 === pack.rulePackSha256 && rulesSha256 === pack.rulesSha256 ? pack : null; + } catch { + return null; + } +} +function createOhProjectionQueryV1(input) { + const queryId = projectionName(input.queryId); + const limit = positiveInteger(input.limit ?? 1000, OH_PROJECTION_LIMITS_V1.queryResults); + if (queryId === null || limit === null || input.find.length < 1 || input.find.length > OH_PROJECTION_LIMITS_V1.arity || input.where.length < 1 || input.where.length > OH_PROJECTION_LIMITS_V1.queryLiterals) + throw new TypeError("Invalid projection query."); + const find = input.find.map((name) => projectionName(name)); + const where = input.where.map(parseOhProjectionLiteralV1); + if (find.some((name) => name === null) || !orderedUnique([...find].sort(), String) || where.some((literal) => literal === null)) + throw new TypeError("Invalid projection query variables or literals."); + const bound = new Set(where.flatMap(literalVariables)); + if (find.some((name) => !bound.has(name)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projected query variable must be bound in the query body."); + } + const payload = { + find, + limit, + queryId, + where, + v: 1 + }; + return { ...payload, querySha256: canonicalSha256(payload) }; +} +function parseOhProjectionQueryV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["find", "limit", "queryId", "querySha256", "where", "v"]) || value.v !== 1 || !Array.isArray(value.find) || !Array.isArray(value.where)) + return null; + const querySha256 = parseSha256Hex(value.querySha256); + try { + const query = createOhProjectionQueryV1({ + find: value.find, + limit: value.limit, + queryId: value.queryId, + where: value.where + }); + return querySha256 !== null && query.querySha256 === querySha256 ? query : null; + } catch { + return null; + } +} +function createOhProjectionIdentityV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const query = parseOhProjectionQueryV1(input.query); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + if (snapshot === null || dataset === null || query === null || rulePack === null) { + throw new TypeError("Invalid projection identity input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + datasetSha256: dataset.datasetSha256, + querySha256: query.querySha256, + rulePackSha256: rulePack.rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, projectionSha256: canonicalSha256(payload) }; +} +function parseOhProjectionIdentityV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "datasetSha256", + "projectionSha256", + "querySha256", + "rulePackSha256", + "semantics", + "snapshotSha256", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const projectionSha256 = parseSha256Hex(value.projectionSha256); + const querySha256 = parseSha256Hex(value.querySha256); + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) + return null; + const payload = { + contractSha256, + datasetSha256, + querySha256, + rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256, + v: 1 + }; + return canonicalSha256(payload) === projectionSha256 ? { ...payload, projectionSha256 } : null; +} +function invalidationForOhProjectionV1(previous, next) { + const parsedPrevious = parseOhProjectionIdentityV1(previous); + const parsedNext = parseOhProjectionIdentityV1(next); + if (parsedPrevious === null || parsedNext === null) + throw new TypeError("Invalid projection identity."); + if (parsedPrevious.projectionSha256 === parsedNext.projectionSha256) + return { kind: "reusable", v: 1 }; + const reasons = []; + if (parsedPrevious.snapshotSha256 !== parsedNext.snapshotSha256) + reasons.push("snapshot-changed"); + if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) + reasons.push("dataset-changed"); + if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) + reasons.push("rule-pack-changed"); + if (parsedPrevious.querySha256 !== parsedNext.querySha256) + reasons.push("query-changed"); + return { kind: "full-rebuild", reasons, v: 1 }; +} +function tupleKey(value) { + return canonicalJson(value); +} +function referenceKey(reference) { + return canonicalJson([reference.relation, reference.tuple]); +} +function relationTuples(relations, relation) { + return [...relations.get(relation)?.values() ?? []].sort((left, right) => compareCanonical(left.tuple, right.tuple)); +} +function setArity(arities, relation, arity) { + const existing = arities.get(relation); + if (existing !== undefined && existing !== arity) { + throw new TypeError(`Projection relation ${relation} is used with conflicting arities.`); + } + arities.set(relation, arity); + if (arities.size > OH_PROJECTION_LIMITS_V1.relations) + throw new RangeError("Projection uses too many relations."); +} +function validateProgramArities(dataset, rulePack, query) { + const arities = new Map; + for (const fact of dataset.facts) + setArity(arities, fact.relation, fact.tuple.length); + for (const rule of rulePack.rules) { + setArity(arities, rule.head.relation, rule.head.terms.length); + for (const literal of rule.body) + setArity(arities, literal.relation, literal.terms.length); + } + for (const literal of query.where) + setArity(arities, literal.relation, literal.terms.length); +} +function sameAtom(left, right) { + return left === right; +} +function unifyLiteral(literal, state, binding) { + const next = new Map(binding); + for (let index = 0;index < literal.terms.length; index += 1) { + const term = literal.terms[index]; + const value = state.tuple[index]; + if (term.kind === "constant") { + if (!sameAtom(term.value, value)) + return null; + continue; + } + if (next.has(term.name)) { + if (!sameAtom(next.get(term.name), value)) + return null; + } else + next.set(term.name, value); + } + return next; +} +function matchBody(relations, body, maximumMatches) { + let matches = [{ binding: new Map, premises: [] }]; + for (const literal of body) { + const next = []; + const candidates = relationTuples(relations, literal.relation); + for (const match of matches) { + for (const candidate of candidates) { + const binding = unifyLiteral(literal, candidate, match.binding); + if (binding === null) + continue; + next.push({ binding, premises: [...match.premises, { + relation: literal.relation, + tuple: candidate.tuple + }] }); + if (next.length > maximumMatches) + throw new RangeError("Projection join exceeds its match bound."); + } + } + matches = next; + if (matches.length === 0) + break; + } + return matches; +} +function instantiateHead(head, binding) { + return head.terms.map((term) => term.kind === "constant" ? term.value : binding.get(term.name)); +} +function canonicalWitness(witness) { + if (witness.kind === "fact") + return canonicalJson(witness); + return canonicalJson({ kind: witness.kind, premises: witness.premises, ruleSha256: witness.rule.ruleSha256 }); +} +function materializeNaive(input) { + const relations = new Map; + for (const fact of input.dataset.facts) { + let relation = relations.get(fact.relation); + if (relation === undefined) { + relation = new Map; + relations.set(fact.relation, relation); + } + relation.set(tupleKey(fact.tuple), { tuple: fact.tuple, witness: { kind: "fact", sources: fact.sources } }); + } + let derivedFacts = 0; + let rounds = 0; + while (true) { + const candidates = new Map; + for (const rule of input.rulePack.rules) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + const derivedTuple = instantiateHead(rule.head, match.binding); + const relation = relations.get(rule.head.relation); + const key = tupleKey(derivedTuple); + if (relation?.has(key) === true) + continue; + const state = { + tuple: derivedTuple, + witness: { kind: "derived", premises: match.premises, rule } + }; + const identity = referenceKey({ relation: rule.head.relation, tuple: derivedTuple }); + const existing = candidates.get(identity); + if (existing === undefined || canonicalWitness(state.witness) < canonicalWitness(existing.state.witness)) { + candidates.set(identity, { relation: rule.head.relation, state }); + } + } + } + if (candidates.size === 0) + break; + if (rounds >= input.maximumRounds) + throw new RangeError("Projection exceeds its evaluation round bound."); + if (derivedFacts + candidates.size > input.maximumDerivedTuples) { + throw new RangeError("Projection exceeds its derived tuple bound."); + } + const ordered = [...candidates.values()].sort((left, right) => compareCanonical([left.relation, left.state.tuple], [right.relation, right.state.tuple])); + for (const candidate of ordered) { + let relation = relations.get(candidate.relation); + if (relation === undefined) { + relation = new Map; + relations.set(candidate.relation, relation); + } + relation.set(tupleKey(candidate.state.tuple), candidate.state); + } + derivedFacts += candidates.size; + rounds += 1; + } + return { baseFacts: input.dataset.facts.length, derivedFacts, relations, rounds }; +} +function boundedOption(value, fallback, maximum, label) { + const parsed = positiveInteger(value ?? fallback, maximum); + if (parsed === null) + throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`); + return parsed; +} +function resolveEvaluationOptions(options) { + return { + maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), + maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), + maximumProofNodes: boundedOption(options.maximumProofNodes, 1024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds") + }; +} +function proofForReference(relations, reference, budget, options, depth, visiting) { + if (budget.nodes >= options.maximumProofNodes) + return null; + if (budget.nodes === options.maximumProofNodes - 1) { + budget.nodes += 1; + return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + budget.nodes += 1; + if (depth >= options.maximumProofDepth) { + return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const identity = referenceKey(reference); + if (visiting.has(identity)) { + return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); + if (state === undefined) + throw new Error("Projection proof references a tuple outside the materialized result."); + if (state.witness.kind === "fact") { + return { + kind: "fact", + relation: reference.relation, + sources: state.witness.sources, + tuple: reference.tuple, + v: 1 + }; + } + visiting.add(identity); + try { + const premises = []; + for (const premise of state.witness.premises) { + const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); + if (proof === null) + break; + premises.push(proof); + } + return { + kind: "derived", + premises, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + } finally { + visiting.delete(identity); + } +} +function buildProjectionResult(input) { + const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches); + const byValues = new Map; + for (const match of matches) { + const values = input.query.find.map((name) => match.binding.get(name)); + const key = tupleKey(values); + const existing = byValues.get(key); + if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) + byValues.set(key, match); + } + const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + const truncated = ordered.length > input.query.limit; + const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const values = JSON.parse(key); + const budget = { nodes: 0 }; + const proofs = []; + for (const premise of match.premises) { + const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set); + if (proof === null) + break; + proofs.push(proof); + } + return { proofs, values, v: 1 }; + }); + const identity = createOhProjectionIdentityV1({ + dataset: input.dataset, + query: input.query, + rulePack: input.rulePack, + snapshot: input.snapshot + }); + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine: input.engine, + evaluation: { ...input.options, v: 1 }, + identity, + rows, + stats: { + baseFacts: input.materialized.baseFacts, + derivedFacts: input.materialized.derivedFacts, + queryMatches: matches.length, + relations: input.materialized.relations.size, + rounds: input.materialized.rounds, + truncated, + v: 1 + }, + v: 1 + }; + return { ...payload, resultSha256: canonicalSha256(payload) }; +} +function evaluateOhProjectionV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + if (snapshot === null || dataset === null || rulePack === null || query === null) { + throw new TypeError("Invalid projection snapshot, dataset, rule pack, or query."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const materialized = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack + }); + return buildProjectionResult({ + dataset, + engine: OH_PROJECTION_INTERNAL_ENGINE_V1, + materialized, + options, + query, + rulePack, + snapshot + }); +} +function evaluateOhProjectionWithMaterializerV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + const engine = safeCode(input.engine, 256); + if (snapshot === null || dataset === null || rulePack === null || query === null || engine === null) { + throw new TypeError("Invalid projection adapter input."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const external = input.materialize({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + query, + rulePack + }); + const witnessMaterialization = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack + }); + const externalCanonical = new Map; + for (const [relationName, tuples] of external.relationFacts) { + const relation = projectionName(relationName); + if (relation === null || tuples.length > OH_PROJECTION_LIMITS_V1.facts + options.maximumDerivedTuples) { + throw new TypeError("Projection adapter returned an invalid relation."); + } + const parsed = tuples.map(tuple); + if (parsed.some((value) => value === null)) + throw new TypeError("Projection adapter returned an invalid tuple."); + const keys = []; + for (const value of parsed) { + if (value === null) + throw new TypeError("Projection adapter returned an invalid tuple."); + keys.push(tupleKey(value)); + } + externalCanonical.set(relation, [...new Set(keys)].sort()); + } + const expectedCanonical = new Map([...witnessMaterialization.relations.entries()].map(([relation, states]) => [relation, [...states.values()].map((state) => tupleKey(state.tuple)).sort()])); + const relationNames = [...new Set([...externalCanonical.keys(), ...expectedCanonical.keys()])].sort(); + for (const relation of relationNames) { + if (canonicalJson(externalCanonical.get(relation) ?? []) !== canonicalJson(expectedCanonical.get(relation) ?? [])) { + throw new Error(`Projection adapter disagrees with Oh semantics for relation ${relation}.`); + } + } + return buildProjectionResult({ + dataset, + engine, + materialized: witnessMaterialization, + options, + query, + rulePack, + snapshot + }); +} +function createOhProjectionRecordFactsV1(records, options = {}) { + if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + throw new RangeError("Too many records for projection facts."); + const facts = []; + for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { + const record = parseKnowledgeGraphRecordV1(candidate); + if (record === null) + throw new TypeError("Invalid graph record for projection facts."); + const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 }]; + if (options.includeRecords !== false) { + facts.push(createOhProjectionFactV1({ + relation: "oh.record", + sources: source, + tuple: [record.key, record.kind, record.recordSha256] + })); + } + if (options.includeDependencies !== false) { + for (const dependency of record.dependencies) { + facts.push(createOhProjectionFactV1({ + relation: "oh.dependency", + sources: source, + tuple: [record.key, dependency] + })); + } + } + } + return facts.sort(compareProjectionFacts); +} +function isOhProjectionRecordKindV1(value) { + return OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === value); +} + +// src/projection-public.ts +var OH_PROJECTION_FORMAT_VERSION_V12 = OH_PROJECTION_FORMAT_VERSION_V1; +var OH_PROJECTION_INTERNAL_ENGINE_V12 = OH_PROJECTION_INTERNAL_ENGINE_V1; +var OH_PROJECTION_LIMITS_V12 = OH_PROJECTION_LIMITS_V1; +var OH_PROJECTION_RECORD_FACT_EXTRACTOR_V12 = OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1; +var OH_PROJECTION_SEMANTICS_V12 = OH_PROJECTION_SEMANTICS_V1; +var createOhProjectionDatasetV12 = createOhProjectionDatasetV1; +var createOhProjectionFactV12 = createOhProjectionFactV1; +var createOhProjectionIdentityV12 = createOhProjectionIdentityV1; +var createOhProjectionLiteralV12 = createOhProjectionLiteralV1; +var createOhProjectionQueryV12 = createOhProjectionQueryV1; +var createOhProjectionRecordFactsV12 = createOhProjectionRecordFactsV1; +var createOhProjectionRulePackV12 = createOhProjectionRulePackV1; +var createOhProjectionRuleV12 = createOhProjectionRuleV1; +var createOhProjectionSnapshotV12 = createOhProjectionSnapshotV1; +var evaluateOhProjectionV12 = evaluateOhProjectionV1; +var invalidationForOhProjectionV12 = invalidationForOhProjectionV1; +var isOhProjectionRecordKindV12 = isOhProjectionRecordKindV1; +var ohProjectionConstantV12 = ohProjectionConstantV1; +var ohProjectionVariableV12 = ohProjectionVariableV1; +var parseOhProjectionDatasetV12 = parseOhProjectionDatasetV1; +var parseOhProjectionFactV12 = parseOhProjectionFactV1; +var parseOhProjectionIdentityV12 = parseOhProjectionIdentityV1; +var parseOhProjectionLiteralV12 = parseOhProjectionLiteralV1; +var parseOhProjectionQueryV12 = parseOhProjectionQueryV1; +var parseOhProjectionRulePackV12 = parseOhProjectionRulePackV1; +var parseOhProjectionRuleV12 = parseOhProjectionRuleV1; +var parseOhProjectionSnapshotV12 = parseOhProjectionSnapshotV1; +var parseOhProjectionTermV12 = parseOhProjectionTermV1; +export { + parseOhProjectionTermV12 as parseOhProjectionTermV1, + parseOhProjectionSnapshotV12 as parseOhProjectionSnapshotV1, + parseOhProjectionRuleV12 as parseOhProjectionRuleV1, + parseOhProjectionRulePackV12 as parseOhProjectionRulePackV1, + parseOhProjectionQueryV12 as parseOhProjectionQueryV1, + parseOhProjectionLiteralV12 as parseOhProjectionLiteralV1, + parseOhProjectionIdentityV12 as parseOhProjectionIdentityV1, + parseOhProjectionFactV12 as parseOhProjectionFactV1, + parseOhProjectionDatasetV12 as parseOhProjectionDatasetV1, + ohProjectionVariableV12 as ohProjectionVariableV1, + ohProjectionConstantV12 as ohProjectionConstantV1, + isOhProjectionRecordKindV12 as isOhProjectionRecordKindV1, + invalidationForOhProjectionV12 as invalidationForOhProjectionV1, + evaluateOhProjectionV12 as evaluateOhProjectionV1, + createOhProjectionSnapshotV12 as createOhProjectionSnapshotV1, + createOhProjectionRuleV12 as createOhProjectionRuleV1, + createOhProjectionRulePackV12 as createOhProjectionRulePackV1, + createOhProjectionRecordFactsV12 as createOhProjectionRecordFactsV1, + createOhProjectionQueryV12 as createOhProjectionQueryV1, + createOhProjectionLiteralV12 as createOhProjectionLiteralV1, + createOhProjectionIdentityV12 as createOhProjectionIdentityV1, + createOhProjectionFactV12 as createOhProjectionFactV1, + createOhProjectionDatasetV12 as createOhProjectionDatasetV1, + OH_PROJECTION_SEMANTICS_V12 as OH_PROJECTION_SEMANTICS_V1, + OH_PROJECTION_RECORD_FACT_EXTRACTOR_V12 as OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1, + OH_PROJECTION_LIMITS_V12 as OH_PROJECTION_LIMITS_V1, + OH_PROJECTION_INTERNAL_ENGINE_V12 as OH_PROJECTION_INTERNAL_ENGINE_V1, + OH_PROJECTION_FORMAT_VERSION_V12 as OH_PROJECTION_FORMAT_VERSION_V1 +}; diff --git a/dist/projection-suss.d.ts b/dist/projection-suss.d.ts new file mode 100644 index 0000000..14367a2 --- /dev/null +++ b/dist/projection-suss.d.ts @@ -0,0 +1,17 @@ +import { type OhProjectionDatasetV1, type OhProjectionEvaluationOptionsV1, type OhProjectionQueryV1, type OhProjectionResultV1, type OhProjectionRulePackV1, type OhProjectionSnapshotV1 } from "./projection"; +export declare const OH_PROJECTION_SUSS_VERSION_V1: "0.20.0"; +export declare const OH_PROJECTION_SUSS_ENGINE_V1: "suss.datalog.v0-20-0.equivalence"; +/** + * Evaluates the positive rule pack with Suss 0.20.0, then requires its complete + * relation sets to equal Oh's bounded reference semantics before returning the + * canonical derived-only result. The conservative admission check keeps Suss, + * whose public evaluator has no execution-budget hook, inside Oh's tuple bound. + */ +export declare function evaluateOhProjectionWithSussV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1; +//# sourceMappingURL=projection-suss.d.ts.map \ No newline at end of file diff --git a/dist/projection-suss.d.ts.map b/dist/projection-suss.d.ts.map new file mode 100644 index 0000000..4705af7 --- /dev/null +++ b/dist/projection-suss.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"projection-suss.d.ts","sourceRoot":"","sources":["../src/projection-suss.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EAEpC,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAE5B,MAAM,cAAc,CAAC;AAEtB,eAAO,MAAM,6BAA6B,EAAG,QAAiB,CAAC;AAC/D,eAAO,MAAM,4BAA4B,EAAG,kCAA2C,CAAC;AA8DxF;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC7D,OAAO,EAAE,qBAAqB,CAAC;IAC/B,OAAO,CAAC,EAAE,+BAA+B,CAAC;IAC1C,KAAK,EAAE,mBAAmB,CAAC;IAC3B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;CAClC,CAAC,GAAG,oBAAoB,CAsBxB"} \ No newline at end of file diff --git a/dist/projection-suss.js b/dist/projection-suss.js new file mode 100644 index 0000000..c5bc62b --- /dev/null +++ b/dist/projection-suss.js @@ -0,0 +1,2026 @@ +// @bun +// src/projection-suss.ts +import { + Database, + constant, + evaluate, + lit, + rule, + variable +} from "@suss/datalog"; + +// src/canonical.ts +import { createHash, randomBytes } from "crypto"; + +class OhValidationError extends Error { + code; + path; + constructor(code, path, message) { + super(`${path}: ${message}`); + this.name = "OhValidationError"; + this.code = code; + this.path = path; + } +} +function isPlainRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasExactKeys(value, keys) { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function assertUnicodeScalarString(value, path) { + 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)) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + index += 1; + } else if (code >= 56320 && code <= 57343) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + } +} +function encodeCanonical(value, path, ancestors) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + assertUnicodeScalarString(value, path); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new OhValidationError("non-json-number", path, "must be finite"); + } + if (Object.is(value, -0)) { + throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null) { + throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`); + } + if (ancestors.has(value)) { + throw new OhValidationError("cycle", path, "contains a cycle"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes"); + } + encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors)); + } + const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)); + if (extraKeys.length > 0) { + throw new OhValidationError("non-json-property", path, "array has non-index properties"); + } + return `[${encoded.join(",")}]`; + } + if (!isPlainRecord(value)) { + throw new OhValidationError("non-plain-object", path, "must be a plain object"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new OhValidationError("non-json-property", path, "object has a symbol property"); + } + const keys = ownKeys; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property"); + } + } + keys.sort(); + const entries = keys.map((key) => { + assertUnicodeScalarString(key, `${path}.`); + return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`; + }); + return `{${entries.join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function canonicalJson(value) { + return encodeCanonical(value, "$", new Set); +} +function parseCanonicalJson(text, maximumBytes = 16 * 1024 * 1024) { + if (utf8ByteLength(text) > maximumBytes) { + throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit"); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new OhValidationError("invalid-json", "$", "is not valid JSON"); + } + if (canonicalJson(value) !== text) { + throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical"); + } + return value; +} +function utf8ByteLength(value) { + return Buffer.byteLength(value, "utf8"); +} +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} +function canonicalSha256(value) { + return sha256Hex(canonicalJson(value)); +} +function parseSha256Hex(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +} +function parseCanonicalInstantV1(value) { + if (typeof value !== "string" || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null; +} +function canonicalNow() { + return new Date().toISOString(); +} +function opaqueId(prefix) { + if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) { + throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code"); + } + return `${prefix}${randomBytes(12).toString("hex")}`; +} +function safeCode(value, maximumLength = 128) { + return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function boundedText(value, maximumBytes = 64 * 1024) { + if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value || utf8ByteLength(value) > maximumBytes) + return null; + try { + assertUnicodeScalarString(value, "$text"); + } catch { + return null; + } + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code <= 8 || code >= 11 && code <= 12 || code >= 14 && code <= 31 || code >= 127 && code <= 159) + return null; + } + return value; +} +function orderedUnique(values, key) { + return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); +} +function sortUnique(values, key) { + const sorted = [...values].sort((left, right) => { + const leftKey = key(left); + const rightKey = key(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + if (!orderedUnique(sorted, key)) { + throw new OhValidationError("duplicate", "$", "contains duplicate canonical values"); + } + return sorted; +} + +// src/graph.ts +var OH_GRAPH_FORMAT_VERSION_V1 = 1; +var OH_GRAPH_LIMITS_V1 = Object.freeze({ + changesPerOperation: 8192, + dependenciesPerRecord: 4096, + recordBytes: 1024 * 1024, + recordsPerSnapshot: 65536 +}); +var OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1 = [ + "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 recordKey(value) { + return typeof value === "string" && value.length <= 512 && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function createKnowledgeGraphRecordV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"]) || input.v !== 1 || !Array.isArray(input.dependencies)) + throw new TypeError("Invalid graph record input."); + const key = recordKey(input.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind); + if (key === null || kind === undefined || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) + throw new TypeError("Invalid graph record identity."); + const dependencies = input.dependencies.map(recordKey); + if (dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) { + throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive."); + } + const valueJson = canonicalJson(input.value); + if (Buffer.byteLength(valueJson, "utf8") > OH_GRAPH_LIMITS_V1.recordBytes) { + throw new RangeError("Graph record value exceeds its canonical byte limit."); + } + const payload = { dependencies, key, kind, v: 1, value: input.value }; + return { ...payload, recordSha256: canonicalSha256(payload) }; +} +function parseKnowledgeGraphRecordV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) + return null; + const recordSha256 = parseSha256Hex(value.recordSha256); + const { recordSha256: _digest, ...input } = value; + try { + const created = createKnowledgeGraphRecordV1(input); + return recordSha256 !== null && created.recordSha256 === recordSha256 ? { ...created, recordSha256 } : null; + } catch { + return null; + } +} +function knowledgeGraphRecordRefV1(record) { + return { + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + sha256: record.recordSha256, + v: 1 + }; +} +function changeKey(change) { + return change.kind === "put" ? change.record.key : change.key; +} +function canonicalKnowledgeGraphChangesV1(changes) { + const normalized = []; + for (const change of changes) { + if (!isPlainRecord(change) || change.v !== 1) + throw new TypeError("Invalid graph change."); + if (change.kind === "put") { + const record = parseKnowledgeGraphRecordV1(change.record); + if (record === null) + throw new TypeError("Invalid graph record in change."); + normalized.push({ kind: "put", record, v: 1 }); + } else if (change.kind === "tombstone") { + const key = recordKey(change.key); + const priorSha256 = parseSha256Hex(change.priorSha256); + if (key === null || priorSha256 === null) + throw new TypeError("Invalid graph tombstone."); + normalized.push({ key, kind: "tombstone", priorSha256, v: 1 }); + } else + throw new TypeError("Unknown graph change kind."); + } + return sortUnique(normalized, changeKey); +} +function graphRevisionSha256V1(input) { + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + const operationId = safeCode(input.operationId); + const parentGraphRevisionSha256 = input.parentGraphRevisionSha256 === null ? null : parseSha256Hex(input.parentGraphRevisionSha256); + const recordsSha256 = parseSha256Hex(input.recordsSha256); + const revision = Number.isSafeInteger(input.revision) && input.revision > 0 ? input.revision : null; + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation || operationId === null || recordsSha256 === null || revision === null || input.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || revision === 1 !== (parentGraphRevisionSha256 === null)) { + throw new TypeError("Invalid graph revision digest input."); + } + return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); +} +function createKnowledgeGraphRevisionV1(input) { + if (input.parent !== null && parseKnowledgeGraphRevisionV1(input.parent) === null) { + throw new TypeError("Invalid parent graph revision."); + } + const operationId = safeCode(input.operationId); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (operationId === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) + throw new TypeError("Invalid graph revision."); + const byKey = new Map((input.parent?.recordRefs ?? []).map((ref) => [ref.key, ref])); + for (const change of changes) { + if (change.kind === "put") { + for (const dependency of change.record.dependencies) { + if (!byKey.has(dependency) && !changes.some((candidate) => candidate.kind === "put" && candidate.record.key === dependency)) { + throw new TypeError(`Missing graph dependency: ${dependency}`); + } + } + byKey.set(change.record.key, knowledgeGraphRecordRefV1(change.record)); + } else { + const prior = byKey.get(change.key); + if (prior === undefined || prior.sha256 !== change.priorSha256) + throw new TypeError("Tombstone prior digest does not match."); + byKey.delete(change.key); + } + } + if (byKey.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph revision exceeds its record snapshot limit."); + } + for (const ref of byKey.values()) { + if (ref.dependencies.some((dependency) => !byKey.has(dependency))) { + throw new TypeError(`Missing graph dependency after revision: ${ref.key}`); + } + } + const recordRefs = [...byKey.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); + const recordsSha256 = canonicalSha256(recordRefs); + const payload = { + changes, + operationId, + parentGraphRevisionSha256: input.parent?.graphRevisionSha256 ?? null, + recordRefs, + recordsSha256, + revision: (input.parent?.revision ?? 0) + 1, + v: 1 + }; + return { ...payload, graphRevisionSha256: graphRevisionSha256V1(payload) }; +} +function parseKnowledgeGraphRevisionV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changes", + "graphRevisionSha256", + "operationId", + "parentGraphRevisionSha256", + "recordRefs", + "recordsSha256", + "revision", + "v" + ]) || value.v !== 1 || !Array.isArray(value.changes) || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + return null; + const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const parentGraphRevisionSha256 = value.parentGraphRevisionSha256 === null ? null : parseSha256Hex(value.parentGraphRevisionSha256); + const operationId = safeCode(value.operationId); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + let changes; + try { + changes = canonicalKnowledgeGraphChangesV1(value.changes); + } catch { + return null; + } + const refs = []; + for (const item of value.recordRefs) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "sha256", "v"]) || item.v !== 1 || !Array.isArray(item.dependencies)) + return null; + const dependencies = item.dependencies.map(recordKey); + const key = recordKey(item.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === item.kind); + const sha256 = parseSha256Hex(item.sha256); + if (key === null || kind === undefined || sha256 === null || dependencies.some((dependency) => dependency === null) || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || !orderedUnique(dependencies, String) || dependencies.includes(key)) + return null; + refs.push({ dependencies, key, kind, sha256, v: 1 }); + } + if (graphRevisionSha256 === null || recordsSha256 === null || operationId === null || revision === null || value.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || !orderedUnique(refs, (ref) => ref.key) || canonicalSha256(refs) !== recordsSha256) + return null; + const keys = new Set(refs.map((ref) => ref.key)); + if (refs.some((ref) => ref.dependencies.some((dependency) => !keys.has(dependency)))) + return null; + const payload = { + changes, + operationId, + parentGraphRevisionSha256, + recordRefs: refs, + recordsSha256, + revision, + v: 1 + }; + try { + return graphRevisionSha256V1(payload) === graphRevisionSha256 ? { ...payload, graphRevisionSha256 } : null; + } catch { + return null; + } +} +function reduceKnowledgeGraphRevisionsV1(revisions) { + if (revisions.length === 0 || revisions.length > 65536) + return null; + const ordered = [...revisions].sort((left, right) => left.revision - right.revision); + let parent = null; + const operationIds = new Set; + for (const candidate of ordered) { + const current = parseKnowledgeGraphRevisionV1(candidate); + if (current === null || current.revision !== (parent?.revision ?? 0) + 1 || current.parentGraphRevisionSha256 !== (parent?.graphRevisionSha256 ?? null) || operationIds.has(current.operationId)) + return null; + try { + const rebuilt = createKnowledgeGraphRevisionV1({ changes: current.changes, operationId: current.operationId, parent }); + if (rebuilt.graphRevisionSha256 !== current.graphRevisionSha256) + return null; + } catch { + return null; + } + operationIds.add(current.operationId); + parent = current; + } + return parent; +} + +// src/ontology.ts +var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; +var OH_CONTRACT_ID_V1 = "oh.ontology.v1"; +var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ + dimensions: 64, + listValues: 256, + qualifiers: 128, + statementBytes: 256 * 1024, + textBytes: 64 * 1024 +}); +var OH_KNOWLEDGE_KERNEL_CONCEPTS_V1 = [ + { code: "entity", description: "A stable identity anchor for something that can be referred to.", label: "Entity" }, + { code: "statement", description: "An immutable proposition with a subject, predicate, object, and qualifiers.", label: "Statement" }, + { code: "assertion", description: "An attributable stance toward a statement.", label: "Assertion" }, + { code: "evidence", description: "A typed account of how an observation bears on an assertion.", label: "Evidence" }, + { code: "context", description: "The scenario and dimensions in which knowledge applies.", label: "Context" }, + { code: "inquiry", description: "A question and its durable investigation trail.", label: "Inquiry" }, + { code: "projection", description: "A reproducible view derived from exact knowledge.", label: "Projection" } +]; +function success(value) { + return { ok: true, value }; +} +function failure(field, code = "invalid-input") { + return { error: { code, field }, ok: false }; +} +function parseOpaqueId(value, prefix) { + return typeof value === "string" && new RegExp(`^${prefix}[a-z0-9]{24}$`, "u").test(value) ? value : null; +} +function parseKnowledgeEntityId(value) { + return parseOpaqueId(value, "kent_"); +} +function parseKnowledgeAssertionId(value) { + return parseOpaqueId(value, "kast_"); +} +function parseKnowledgeEvidenceId(value) { + return parseOpaqueId(value, "kevd_"); +} +function parseKnowledgeInquiryId(value) { + return parseOpaqueId(value, "kinq_"); +} +var OH_KNOWLEDGE_ENTITY_STATES_V1 = ["active", "quarantined", "redirected", "tombstoned"]; +function parseKnowledgeEntityV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "entityId", + "identityOperationId", + "identityRevision", + "redirectEntityId", + "state", + "v" + ]) || value.v !== 1) + return failure("entity"); + const entityId = parseKnowledgeEntityId(value.entityId); + const identityOperationId = safeCode(value.identityOperationId); + const identityRevision = Number.isSafeInteger(value.identityRevision) && value.identityRevision > 0 ? value.identityRevision : null; + const redirectEntityId = value.redirectEntityId === null ? null : parseKnowledgeEntityId(value.redirectEntityId); + const state = OH_KNOWLEDGE_ENTITY_STATES_V1.find((candidate) => candidate === value.state); + return entityId !== null && identityOperationId !== null && identityRevision !== null && (value.redirectEntityId === null || redirectEntityId !== null) && state !== undefined && state === "redirected" === (redirectEntityId !== null) && redirectEntityId !== entityId ? success({ entityId, identityOperationId, identityRevision, redirectEntityId, state, v: 1 }) : failure("entity"); +} +function parseKnowledgeSchemaRefV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["code", "namespace", "revision", "schemaSha256", "v"]) || value.v !== 1) + return failure("schemaRef"); + const code = safeCode(value.code); + const namespace = safeCode(value.namespace); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + const schemaSha256 = parseSha256Hex(value.schemaSha256); + return code !== null && namespace !== null && revision !== null && schemaSha256 !== null ? success({ code, namespace, revision, schemaSha256, v: 1 }) : failure("schemaRef"); +} +var INTEGER = /^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$/u; +var DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/u; +function parseKnowledgeValueInternal(value, depth) { + if (!isPlainRecord(value) || value.v !== 1 || depth > 8) + return null; + switch (value.kind) { + case "entity": { + if (!hasExactKeys(value, ["entityId", "kind", "v"])) + return null; + const entityId = parseKnowledgeEntityId(value.entityId); + return entityId === null ? null : { entityId, kind: "entity", v: 1 }; + } + case "text": { + if (!hasExactKeys(value, ["kind", "language", "text", "v"])) + return null; + const language = typeof value.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(value.language) ? value.language : null; + const text = boundedText(value.text); + return language !== null && text !== null ? { kind: "text", language, text, v: 1 } : null; + } + case "string": { + const parsed = boundedText(value.value); + return hasExactKeys(value, ["kind", "v", "value"]) && parsed !== null ? { kind: "string", v: 1, value: parsed } : null; + } + case "boolean": + return hasExactKeys(value, ["kind", "v", "value"]) && typeof value.value === "boolean" ? { kind: "boolean", v: 1, value: value.value } : null; + case "integer": + case "decimal": { + const valid = typeof value.value === "string" && value.value.length <= 1024 && (value.kind === "integer" ? INTEGER.test(value.value) : DECIMAL.test(value.value) && value.value !== "-0"); + return hasExactKeys(value, ["kind", "v", "value"]) && valid ? { kind: value.kind, v: 1, value: value.value } : null; + } + case "uri": { + if (!hasExactKeys(value, ["kind", "uri", "v"]) || typeof value.uri !== "string" || value.uri.length > 4096) + return null; + try { + const url = new URL(value.uri); + return url.href === value.uri && url.username === "" && url.password === "" && !["data:", "file:", "javascript:"].includes(url.protocol) ? { kind: "uri", uri: value.uri, v: 1 } : null; + } catch { + return null; + } + } + case "list": + case "set": { + if (!hasExactKeys(value, ["kind", "v", "values"]) || !Array.isArray(value.values) || value.values.length > OH_KNOWLEDGE_LIMITS_V1.listValues) + return null; + const values = []; + for (const item of value.values) { + const parsed = parseKnowledgeValueInternal(item, depth + 1); + if (parsed === null) + return null; + values.push(parsed); + } + if (value.kind === "set" && !orderedUnique(values, canonicalJson)) + return null; + return { kind: value.kind, v: 1, values }; + } + case "extension": { + if (!hasExactKeys(value, ["canonicalizerSha256", "canonicalValue", "kind", "mediaType", "schema", "v", "valueSha256"])) + return null; + const canonicalizerSha256 = parseSha256Hex(value.canonicalizerSha256); + const canonicalValue = boundedText(value.canonicalValue, 64 * 1024); + const mediaType = typeof value.mediaType === "string" && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/u.test(value.mediaType) ? value.mediaType : null; + const schema = parseKnowledgeSchemaRefV1(value.schema); + const valueSha256 = parseSha256Hex(value.valueSha256); + return canonicalizerSha256 !== null && canonicalValue !== null && mediaType !== null && schema.ok && valueSha256 !== null ? { canonicalizerSha256, canonicalValue, kind: "extension", mediaType, schema: schema.value, v: 1, valueSha256 } : null; + } + default: + return null; + } +} +function parseKnowledgeValueV1(value) { + const parsed = parseKnowledgeValueInternal(value, 0); + return parsed === null ? failure("value") : success(parsed); +} +function verifyKnowledgeValueV1(value) { + const parsed = parseKnowledgeValueV1(value); + if (!parsed.ok) + return parsed; + if (parsed.value.kind === "extension" && sha256Hex(parsed.value.canonicalValue) !== parsed.value.valueSha256) { + return failure("valueSha256", "digest-mismatch"); + } + if (parsed.value.kind === "list" || parsed.value.kind === "set") { + for (const child of parsed.value.values) { + const verified = verifyKnowledgeValueV1(child); + if (!verified.ok) + return verified; + } + } + return success(parsed.value); +} +function parseDimension(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["predicate", "v", "value"]) || value.v !== 1) + return null; + const predicate = parseKnowledgeSchemaRefV1(value.predicate); + const parsedValue = parseKnowledgeValueV1(value.value); + return predicate.ok && parsedValue.ok ? { predicate: predicate.value, v: 1, value: parsedValue.value } : null; +} +function createKnowledgeContextV1(input) { + if (!isPlainRecord(input) || input.v !== 1 || !Array.isArray(input.dimensions) || input.dimensions.length > OH_KNOWLEDGE_LIMITS_V1.dimensions || !["actual", "counterfactual", "hypothetical", "planned"].includes(input.scenario)) + return failure("context"); + const dimensions = []; + for (const item of input.dimensions) { + const parsed = parseDimension(item); + if (parsed === null) + return failure("dimensions"); + const verified = verifyKnowledgeValueV1(parsed.value); + if (!verified.ok) + return verified; + dimensions.push(parsed); + } + let canonicalDimensions; + try { + canonicalDimensions = sortUnique(dimensions, canonicalJson); + } catch { + return failure("dimensions", "noncanonical-input"); + } + const payload = { dimensions: canonicalDimensions, scenario: input.scenario, v: 1 }; + return success({ ...payload, contextSha256: canonicalSha256(payload) }); +} +function parseKnowledgeContextV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["contextSha256", "dimensions", "scenario", "v"])) + return failure("context"); + const digest = parseSha256Hex(value.contextSha256); + if (digest === null) + return failure("contextSha256"); + const created = createKnowledgeContextV1({ dimensions: value.dimensions, scenario: value.scenario, v: value.v }); + return created.ok && created.value.contextSha256 === digest && canonicalJson(created.value.dimensions) === canonicalJson(value.dimensions) ? success({ ...created.value, contextSha256: digest }) : failure("contextSha256", "digest-mismatch"); +} +function createKnowledgeStatementV1(input) { + const object = parseKnowledgeValueV1(input.object); + const predicate = parseKnowledgeSchemaRefV1(input.predicate); + const subject = parseKnowledgeEntityId(input.subject); + if (input.v !== 1 || !object.ok || !predicate.ok || subject === null || !Array.isArray(input.qualifiers) || input.qualifiers.length > OH_KNOWLEDGE_LIMITS_V1.qualifiers) + return failure("statement"); + const verifiedObject = verifyKnowledgeValueV1(object.value); + if (!verifiedObject.ok) + return verifiedObject; + const qualifiers = []; + for (const item of input.qualifiers) { + const parsed = parseDimension(item); + if (parsed === null) + return failure("qualifiers"); + const verified = verifyKnowledgeValueV1(parsed.value); + if (!verified.ok) + return verified; + qualifiers.push(parsed); + } + let canonicalQualifiers; + try { + canonicalQualifiers = sortUnique(qualifiers, canonicalJson); + } catch { + return failure("qualifiers", "noncanonical-input"); + } + const payload = { object: object.value, predicate: predicate.value, qualifiers: canonicalQualifiers, subject, v: 1 }; + if (Buffer.byteLength(canonicalJson(payload), "utf8") > OH_KNOWLEDGE_LIMITS_V1.statementBytes) + return failure("statement", "limit-exceeded"); + return success({ ...payload, statementSha256: canonicalSha256(payload) }); +} +function parseKnowledgeStatementV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["object", "predicate", "qualifiers", "statementSha256", "subject", "v"])) + return failure("statement"); + const digest = parseSha256Hex(value.statementSha256); + const created = createKnowledgeStatementV1(value); + return digest !== null && created.ok && created.value.statementSha256 === digest && canonicalJson(created.value.qualifiers) === canonicalJson(value.qualifiers) ? success({ ...created.value, statementSha256: digest }) : failure("statementSha256", "digest-mismatch"); +} +function parseKnowledgeAgentRefV1(value) { + if (!isPlainRecord(value) || value.v !== 1) + return null; + if (value.kind === "entity" && hasExactKeys(value, ["entityId", "kind", "v"])) { + const entityId = parseKnowledgeEntityId(value.entityId); + return entityId === null ? null : { entityId, kind: "entity", v: 1 }; + } + if (value.kind === "model" && hasExactKeys(value, ["kind", "model", "receiptSha256", "v"])) { + const model = parseKnowledgeSchemaRefV1(value.model); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + return model.ok && receiptSha256 !== null ? { kind: "model", model: model.value, receiptSha256, v: 1 } : null; + } + if (value.kind === "system" && hasExactKeys(value, ["authority", "kind", "receiptSha256", "v"])) { + const authority = parseKnowledgeSchemaRefV1(value.authority); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + return authority.ok && receiptSha256 !== null ? { authority: authority.value, kind: "system", receiptSha256, v: 1 } : null; + } + return null; +} +function parseDigestArray(value, maximum = 2048) { + if (!Array.isArray(value) || value.length > maximum) + return null; + const digests = value.map(parseSha256Hex); + return digests.every((digest) => digest !== null) && orderedUnique(digests, String) ? digests : null; +} +var OH_KNOWLEDGE_ACTIVITY_KINDS_V1 = [ + "extraction", + "human-entry", + "human-review", + "import", + "model-proposal", + "normalization", + "publication", + "resolution", + "transformation" +]; +function parseActivityInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actor", + "inputSha256s", + "kind", + "occurredAt", + "outputSha256s", + "policySha256", + "tool", + "v" + ]) || value.v !== 1) + return null; + const actor = parseKnowledgeAgentRefV1(value.actor); + const inputSha256s = parseDigestArray(value.inputSha256s); + const kind = OH_KNOWLEDGE_ACTIVITY_KINDS_V1.find((candidate) => candidate === value.kind); + const occurredAt = parseCanonicalInstantV1(value.occurredAt); + const outputSha256s = parseDigestArray(value.outputSha256s); + const policySha256 = parseSha256Hex(value.policySha256); + const tool = value.tool === null ? null : parseKnowledgeSchemaRefV1(value.tool); + const parsedTool = tool === null ? null : tool.ok ? tool.value : null; + return actor !== null && inputSha256s !== null && kind !== undefined && occurredAt !== null && outputSha256s !== null && policySha256 !== null && (value.tool === null || parsedTool !== null) ? { actor, inputSha256s, kind, occurredAt, outputSha256s, policySha256, tool: parsedTool, v: 1 } : null; +} +function createKnowledgeActivityV1(input) { + const parsed = parseActivityInput(input); + return parsed === null ? failure("activity") : success({ ...parsed, activitySha256: canonicalSha256(parsed) }); +} +function parseKnowledgeActivityV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "activitySha256")) + return failure("activity"); + const activitySha256 = parseSha256Hex(value.activitySha256); + const { activitySha256: _digest, ...input } = value; + const parsed = parseActivityInput(input); + return activitySha256 !== null && parsed !== null && canonicalSha256(parsed) === activitySha256 ? success({ ...parsed, activitySha256 }) : failure("activitySha256", "digest-mismatch"); +} +var OH_KNOWLEDGE_ASSERTION_STANCES_V1 = ["questions", "refutes", "reports", "supports", "undetermined"]; +var OH_KNOWLEDGE_ASSERTION_STATES_V1 = [ + "accepted-for-purpose", + "disputed", + "proposed", + "reviewed", + "superseded", + "withdrawn" +]; +function parseStringCodes(value, maximum) { + if (!Array.isArray(value) || value.length > maximum) + return null; + const codes = value.map((item) => safeCode(item)); + return codes.every((code) => code !== null) && orderedUnique(codes, String) ? codes : null; +} +function parseAssertionInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "acceptedPurposes", + "assertionId", + "assertor", + "confidence", + "contextSha256", + "provenanceActivitySha256", + "reviewActivitySha256", + "stance", + "state", + "statementSha256", + "v" + ]) || value.v !== 1) + return null; + const acceptedPurposes = parseStringCodes(value.acceptedPurposes, 32); + const assertionId = parseKnowledgeAssertionId(value.assertionId); + const assertor = parseKnowledgeAgentRefV1(value.assertor); + const confidence = value.confidence === null ? null : parseKnowledgeSchemaRefV1(value.confidence); + const parsedConfidence = confidence === null ? null : confidence.ok ? confidence.value : null; + const contextSha256 = value.contextSha256 === null ? null : parseSha256Hex(value.contextSha256); + const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); + const reviewActivitySha256 = value.reviewActivitySha256 === null ? null : parseSha256Hex(value.reviewActivitySha256); + const stance = OH_KNOWLEDGE_ASSERTION_STANCES_V1.find((candidate) => candidate === value.stance); + const state = OH_KNOWLEDGE_ASSERTION_STATES_V1.find((candidate) => candidate === value.state); + const statementSha256 = parseSha256Hex(value.statementSha256); + if (acceptedPurposes === null || assertionId === null || assertor === null || value.confidence !== null && parsedConfidence === null || value.contextSha256 !== null && contextSha256 === null || provenanceActivitySha256 === null || value.reviewActivitySha256 !== null && reviewActivitySha256 === null || stance === undefined || state === undefined || statementSha256 === null) + return null; + if (assertor.kind === "model" && (state !== "proposed" || acceptedPurposes.length !== 0 || reviewActivitySha256 !== null)) + return null; + if (state === "accepted-for-purpose" !== acceptedPurposes.length > 0 || state !== "proposed" && reviewActivitySha256 === null) + return null; + return { + acceptedPurposes, + assertionId, + assertor, + confidence: parsedConfidence, + contextSha256, + provenanceActivitySha256, + reviewActivitySha256, + stance, + state, + statementSha256, + v: 1 + }; +} +function createKnowledgeAssertionV1(input) { + const parsed = parseAssertionInput(input); + return parsed === null ? failure("assertion") : success({ ...parsed, assertionSha256: canonicalSha256(parsed) }); +} +function parseKnowledgeAssertionV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "assertionSha256")) + return failure("assertion"); + const assertionSha256 = parseSha256Hex(value.assertionSha256); + const { assertionSha256: _digest, ...input } = value; + const parsed = parseAssertionInput(input); + return assertionSha256 !== null && parsed !== null && canonicalSha256(parsed) === assertionSha256 ? success({ ...parsed, assertionSha256 }) : failure("assertionSha256", "digest-mismatch"); +} +var OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1 = [ + "background", + "contradicts", + "corroborates", + "direct-observation", + "method", + "quotation", + "registry-record", + "supports" +]; +function parseEvidenceInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "assertionSha256", + "bearing", + "disclosure", + "evidenceId", + "observationSha256", + "provenanceActivitySha256", + "selector", + "sourceEntityId", + "v" + ]) || value.v !== 1) + return null; + const assertionSha256 = parseSha256Hex(value.assertionSha256); + const bearing = OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1.find((candidate) => candidate === value.bearing); + const evidenceId = parseKnowledgeEvidenceId(value.evidenceId); + const observationSha256 = value.observationSha256 === null ? null : parseSha256Hex(value.observationSha256); + const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); + const selector = value.selector === null ? null : boundedText(value.selector, 8192); + const sourceEntityId = value.sourceEntityId === null ? null : parseKnowledgeEntityId(value.sourceEntityId); + return assertionSha256 !== null && bearing !== undefined && (value.disclosure === "private" || value.disclosure === "public" || value.disclosure === "shared") && evidenceId !== null && (value.observationSha256 === null || observationSha256 !== null) && provenanceActivitySha256 !== null && (value.selector === null || selector !== null) && (value.sourceEntityId === null || sourceEntityId !== null) && (observationSha256 !== null || sourceEntityId !== null) ? { + assertionSha256, + bearing, + disclosure: value.disclosure, + evidenceId, + observationSha256, + provenanceActivitySha256, + selector, + sourceEntityId, + v: 1 + } : null; +} +function createKnowledgeEvidenceLinkV1(input) { + const parsed = parseEvidenceInput(input); + return parsed === null ? failure("evidence") : success({ ...parsed, evidenceSha256: canonicalSha256(parsed) }); +} +function parseKnowledgeEvidenceLinkV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "evidenceSha256")) + return failure("evidence"); + const evidenceSha256 = parseSha256Hex(value.evidenceSha256); + const { evidenceSha256: _digest, ...input } = value; + const parsed = parseEvidenceInput(input); + return evidenceSha256 !== null && parsed !== null && canonicalSha256(parsed) === evidenceSha256 ? success({ ...parsed, evidenceSha256 }) : failure("evidenceSha256", "digest-mismatch"); +} +function createKnowledgeInquiryV1(input) { + const answerForm = safeCode(input.answerForm); + const authorEntityId = parseKnowledgeEntityId(input.authorEntityId); + const contextSha256 = input.contextSha256 === null ? null : parseSha256Hex(input.contextSha256); + const createdAt = parseCanonicalInstantV1(input.createdAt); + const inquiryId = parseKnowledgeInquiryId(input.inquiryId); + const language = typeof input.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(input.language) ? input.language : null; + const parents = Array.isArray(input.parentInquiryIds) ? input.parentInquiryIds.map(parseKnowledgeInquiryId) : null; + const question = boundedText(input.question, 16384); + if (input.v !== 1 || answerForm === null || authorEntityId === null || input.contextSha256 !== null && contextSha256 === null || createdAt === null || inquiryId === null || language === null || parents === null || parents.some((item) => item === null) || !orderedUnique(parents, String) || !["private", "public", "shared"].includes(input.privacy) || question === null || !["abandoned", "open", "paused", "resolved"].includes(input.status)) + return failure("inquiry"); + const payload = { + answerForm, + authorEntityId, + contextSha256, + createdAt, + inquiryId, + language, + parentInquiryIds: parents, + privacy: input.privacy, + question, + status: input.status, + v: 1 + }; + return success({ ...payload, inquirySha256: canonicalSha256(payload) }); +} +function parseKnowledgeInquiryV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "inquirySha256")) + return failure("inquiry"); + const digest = parseSha256Hex(value.inquirySha256); + const { inquirySha256: _digest, ...input } = value; + const created = createKnowledgeInquiryV1(input); + return digest !== null && created.ok && created.value.inquirySha256 === digest ? success({ ...created.value, inquirySha256: digest }) : failure("inquirySha256", "digest-mismatch"); +} + +// src/schema.ts +var OH_SCHEMA_FORMAT_VERSION_V1 = 1; +var OH_SCHEMA_KINDS_V1 = ["concept", "mapping", "predicate", "shape", "unit", "vocabulary"]; +function parseLocalizedTexts(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > 128) + return null; + const output = []; + for (const item of value) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["language", "text", "v"]) || item.v !== 1) + return null; + const language = typeof item.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(item.language) ? item.language : null; + const text = boundedText(item.text, 16384); + if (language === null || text === null) + return null; + output.push({ language, text, v: 1 }); + } + return orderedUnique(output, canonicalJson) ? output : null; +} +function parseSchemaInput(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "body", + "code", + "compatibility", + "description", + "kind", + "labels", + "namespace", + "previousSchemaSha256", + "revision", + "v" + ]) || value.v !== 1 || !isPlainRecord(value.body)) + return null; + try { + canonicalJson(value.body); + } catch { + return null; + } + const code = safeCode(value.code); + const namespace = safeCode(value.namespace); + const kind = OH_SCHEMA_KINDS_V1.find((candidate) => candidate === value.kind); + const labels = parseLocalizedTexts(value.labels); + const description = parseLocalizedTexts(value.description); + const previousSchemaSha256 = value.previousSchemaSha256 === null ? null : parseSha256Hex(value.previousSchemaSha256); + const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; + const compatibility = value.compatibility === "additive" || value.compatibility === "breaking" ? value.compatibility : null; + return code !== null && namespace !== null && kind !== undefined && labels !== null && description !== null && (value.previousSchemaSha256 === null || previousSchemaSha256 !== null) && revision !== null && compatibility !== null && revision === 1 === (previousSchemaSha256 === null) && (revision !== 1 || compatibility === "additive") ? { + body: value.body, + code, + compatibility, + description, + kind, + labels, + namespace, + previousSchemaSha256, + revision, + v: 1 + } : null; +} +function createKnowledgeSchemaRevisionV1(input) { + const parsed = parseSchemaInput(input); + if (parsed === null) + throw new TypeError("Invalid schema revision input."); + return { ...parsed, schemaSha256: canonicalSha256(parsed) }; +} +function parseKnowledgeSchemaRevisionV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "schemaSha256")) + return null; + const schemaSha256 = parseSha256Hex(value.schemaSha256); + const { schemaSha256: _digest, ...input } = value; + const parsed = parseSchemaInput(input); + return schemaSha256 !== null && parsed !== null && canonicalSha256(parsed) === schemaSha256 ? { ...parsed, schemaSha256 } : null; +} +function knowledgeSchemaRefV1(schema) { + return { + code: schema.code, + namespace: schema.namespace, + revision: schema.revision, + schemaSha256: schema.schemaSha256, + v: 1 + }; +} +function additiveBodyRetainsPrior(prior, next) { + return Object.entries(prior).every(([key, value]) => Object.hasOwn(next, key) && canonicalJson(next[key]) === canonicalJson(value)); +} +function verifyKnowledgeSchemaEvolutionV1(prior, next) { + if (parseKnowledgeSchemaRevisionV1(prior) === null || parseKnowledgeSchemaRevisionV1(next) === null) { + return { ok: false, reason: "invalid-schema" }; + } + if (prior.namespace !== next.namespace || prior.code !== next.code || prior.kind !== next.kind) { + return { ok: false, reason: "identity-changed" }; + } + if (next.revision !== prior.revision + 1 || next.previousSchemaSha256 !== prior.schemaSha256) { + return { ok: false, reason: "chain-broken" }; + } + if (next.compatibility === "additive" && !additiveBodyRetainsPrior(prior.body, next.body)) { + return { ok: false, reason: "false-additive-claim" }; + } + return { ok: true }; +} +function createKnowledgeVocabularyRevisionV1(input) { + const namespace = safeCode(input.namespace); + if (namespace === null || input.v !== 1 || !Number.isSafeInteger(input.revision) || input.revision < 1 || !Array.isArray(input.schemaRefs) || input.schemaRefs.length > 65536) { + throw new TypeError("Invalid vocabulary revision input."); + } + const refs = []; + for (const candidate of input.schemaRefs) { + const parsed = parseKnowledgeSchemaRefV1(candidate); + if (!parsed.ok || parsed.value.namespace !== namespace) + throw new TypeError("Invalid vocabulary schema reference."); + refs.push(parsed.value); + } + if (!orderedUnique(refs, canonicalJson)) + throw new TypeError("Vocabulary schema references must be ordered and unique."); + const payload = { namespace, revision: input.revision, schemaRefs: refs, v: 1 }; + return { ...payload, vocabularySha256: canonicalSha256(payload) }; +} +function parseKnowledgeVocabularyRevisionV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["namespace", "revision", "schemaRefs", "v", "vocabularySha256"])) + return null; + const digest = parseSha256Hex(value.vocabularySha256); + try { + const created = createKnowledgeVocabularyRevisionV1({ + namespace: value.namespace, + revision: value.revision, + schemaRefs: value.schemaRefs, + v: value.v + }); + return digest !== null && created.vocabularySha256 === digest ? { ...created, vocabularySha256: digest } : null; + } catch { + return null; + } +} + +// src/contract.ts +var manifestPayload = Object.freeze({ + contractId: OH_CONTRACT_ID_V1, + graphFormatVersion: OH_GRAPH_FORMAT_VERSION_V1, + ontologyVersion: OH_ONTOLOGY_VERSION_V1, + recordKinds: OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + schemaFormatVersion: OH_SCHEMA_FORMAT_VERSION_V1, + v: 1 +}); +var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ + ...manifestPayload, + contractSha256: canonicalSha256(manifestPayload) +}); +function parseOhContractManifestV1(value) { + try { + return canonicalJson(value) === canonicalJson(OH_CONTRACT_MANIFEST_V1) ? OH_CONTRACT_MANIFEST_V1 : null; + } catch { + return null; + } +} + +class OhRecordCodecRegistry { + #codecs = new Map; + register(codec) { + if (this.#codecs.has(codec.kind)) + throw new TypeError(`A codec is already registered for ${codec.kind}.`); + this.#codecs.set(codec.kind, codec); + return this; + } + parse(kind, value) { + const codec = this.#codecs.get(kind); + if (codec !== undefined) + return codec.parse(value); + try { + canonicalJson(value); + return value; + } catch { + return null; + } + } + has(kind) { + return this.#codecs.has(kind); + } +} + +// src/projection.ts +var OH_PROJECTION_FORMAT_VERSION_V1 = 1; +var OH_PROJECTION_SEMANTICS_V1 = "oh.projection.positive-datalog.v1"; +var OH_PROJECTION_INTERNAL_ENGINE_V1 = "oh.naive.positive.v1"; +var OH_PROJECTION_LIMITS_V1 = Object.freeze({ + arity: 32, + atomBytes: 16 * 1024, + derivedTuples: 262144, + facts: 262144, + literalsPerRule: 64, + proofDepth: 128, + proofNodes: 4096, + queryLiterals: 64, + queryMatches: 262144, + queryResults: 65536, + relations: 4096, + rounds: 1024, + rules: 1024, + sourcesPerFact: 64, + variables: 256 +}); +var recordFactExtractorPayloadV1 = { + factPackId: "oh.record-facts", + factPackRevision: 1, + relations: ["oh.dependency", "oh.record"], + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 +}; +var OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1 = Object.freeze({ + ...recordFactExtractorPayloadV1, + extractorSha256: canonicalSha256(recordFactExtractorPayloadV1) +}); +function nonnegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} +function positiveInteger(value, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : null; +} +function projectionName(value, maximumLength = 128) { + return safeCode(value, maximumLength); +} +function compareCanonical(left, right) { + const leftKey = canonicalJson(left); + const rightKey = canonicalJson(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; +} +function compareProjectionFacts(left, right) { + return compareCanonical([left.relation, left.tuple], [right.relation, right.tuple]); +} +var INVALID_PROJECTION_ATOM = Symbol("invalid-projection-atom"); +function atom(value) { + if (value !== null && typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string") { + return INVALID_PROJECTION_ATOM; + } + try { + const encoded = canonicalJson(value); + return utf8ByteLength(encoded) <= OH_PROJECTION_LIMITS_V1.atomBytes ? value : INVALID_PROJECTION_ATOM; + } catch { + return INVALID_PROJECTION_ATOM; + } +} +function tuple(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > OH_PROJECTION_LIMITS_V1.arity) + return null; + const parsed = value.map(atom); + return parsed.some((item) => item === INVALID_PROJECTION_ATOM) ? null : parsed; +} +function parseRecordRef(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["dependencies", "key", "kind", "sha256", "v"]) || value.v !== 1 || !Array.isArray(value.dependencies)) + return null; + const key = safeCode(value.key, 512); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === value.kind); + const sha256 = parseSha256Hex(value.sha256); + const dependencies = value.dependencies.map((dependency) => safeCode(dependency, 512)); + if (key === null || kind === undefined || sha256 === null || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) + return null; + return { dependencies, key, kind, sha256, v: 1 }; +} +function createOhProjectionSnapshotV1(input) { + const spaceId = projectionName(input.spaceId); + const generation = nonnegativeInteger(input.head.generation); + const sequence = nonnegativeInteger(input.head.sequence); + const operationSha256 = input.head.operationSha256 === null ? null : parseSha256Hex(input.head.operationSha256); + const graphRevisionSha256 = input.head.graphRevisionSha256 === null ? null : parseSha256Hex(input.head.graphRevisionSha256); + const declaredRecordsSha256 = parseSha256Hex(input.head.recordsSha256); + if (spaceId === null || generation === null || sequence === null || generation !== sequence || input.head.operationSha256 !== null && operationSha256 === null || input.head.graphRevisionSha256 !== null && graphRevisionSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || declaredRecordsSha256 === null || input.records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid projection snapshot head."); + } + const records = input.records.map(parseKnowledgeGraphRecordV1); + if (records.some((record) => record === null)) + throw new TypeError("Invalid record in projection snapshot."); + const recordRefs = sortUnique(records.map(knowledgeGraphRecordRefV1), (reference) => reference.key); + const recordsSha256 = canonicalSha256(recordRefs); + if (recordsSha256 !== declaredRecordsSha256) { + throw new TypeError("Projection snapshot records do not reproduce the declared head."); + } + const keys = new Set(recordRefs.map((reference) => reference.key)); + if (recordRefs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) { + throw new TypeError("Projection snapshot has a missing record dependency."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return { ...payload, snapshotSha256: canonicalSha256(payload) }; +} +function parseOhProjectionSnapshotV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "generation", + "graphRevisionSha256", + "operationSha256", + "recordRefs", + "recordsSha256", + "sequence", + "snapshotSha256", + "spaceId", + "v" + ]) || value.v !== 1 || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const generation = nonnegativeInteger(value.generation); + const sequence = nonnegativeInteger(value.sequence); + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + const spaceId = projectionName(value.spaceId); + const recordRefs = value.recordRefs.map(parseRecordRef); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || generation === null || sequence === null || generation !== sequence || spaceId === null || recordsSha256 === null || snapshotSha256 === null || value.graphRevisionSha256 !== null && graphRevisionSha256 === null || value.operationSha256 !== null && operationSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || recordRefs.some((reference) => reference === null)) + return null; + const refs = recordRefs; + if (!orderedUnique(refs, (reference) => reference.key) || canonicalSha256(refs) !== recordsSha256) + return null; + const keys = new Set(refs.map((reference) => reference.key)); + if (refs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) + return null; + const payload = { + contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs: refs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return canonicalSha256(payload) === snapshotSha256 ? { ...payload, snapshotSha256 } : null; +} +function createOhProjectionFactV1(input) { + const relation = projectionName(input.relation); + const parsedTuple = tuple(input.tuple); + if (relation === null || parsedTuple === null || input.sources.length < 1 || input.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) { + throw new TypeError("Invalid projection fact."); + } + const sources = input.sources.map((source) => { + if (!isPlainRecord(source) || !hasExactKeys(source, ["key", "recordSha256", "v"]) || source.v !== 1) { + throw new TypeError("Invalid projection fact source."); + } + const key = safeCode(source.key, 512); + const recordSha256 = parseSha256Hex(source.recordSha256); + if (key === null || recordSha256 === null) + throw new TypeError("Invalid projection fact source."); + return { key, recordSha256, v: 1 }; + }).sort(compareCanonical); + if (!orderedUnique(sources, (source) => source.key)) { + throw new TypeError("Projection fact sources must have unique record keys."); + } + const payload = { relation, sources, tuple: parsedTuple, v: 1 }; + return { ...payload, factSha256: canonicalSha256(payload) }; +} +function parseOhProjectionFactV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["factSha256", "relation", "sources", "tuple", "v"]) || value.v !== 1 || !Array.isArray(value.sources) || !Array.isArray(value.tuple)) + return null; + const factSha256 = parseSha256Hex(value.factSha256); + try { + const fact = createOhProjectionFactV1({ + relation: value.relation, + sources: value.sources, + tuple: value.tuple + }); + return factSha256 !== null && fact.factSha256 === factSha256 ? fact : null; + } catch { + return null; + } +} +function mergeProjectionFacts(facts) { + const grouped = new Map; + for (const fact of facts) { + const identity = canonicalJson([fact.relation, fact.tuple]); + let group = grouped.get(identity); + if (group === undefined) { + group = { relation: fact.relation, sources: new Map, tuple: fact.tuple }; + grouped.set(identity, group); + } + for (const source of fact.sources) { + const existing = group.sources.get(source.key); + if (existing !== undefined && existing.recordSha256 !== source.recordSha256) { + throw new TypeError("One fact source key is bound to multiple record digests."); + } + group.sources.set(source.key, source); + } + } + return [...grouped.values()].map((group) => createOhProjectionFactV1({ + relation: group.relation, + sources: [...group.sources.values()], + tuple: group.tuple + })).sort(compareProjectionFacts); +} +function createOhProjectionDatasetV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const extractorSha256 = parseSha256Hex(input.extractorSha256); + const factPackId = projectionName(input.factPackId); + const factPackRevision = positiveInteger(input.factPackRevision); + if (snapshot === null || extractorSha256 === null || factPackId === null || factPackRevision === null || input.facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new TypeError("Invalid projection dataset."); + const parsedFacts = input.facts.map(parseOhProjectionFactV1); + if (parsedFacts.some((fact) => fact === null)) + throw new TypeError("Invalid fact in projection dataset."); + const facts = mergeProjectionFacts(parsedFacts); + if (facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new RangeError("Projection dataset has too many facts."); + const refs = new Map(snapshot.recordRefs.map((reference) => [reference.key, reference.sha256])); + for (const fact of facts) { + for (const source of fact.sources) { + if (refs.get(source.key) !== source.recordSha256) { + throw new TypeError("Projection fact source is not present at the exact input snapshot."); + } + } + } + const factPackPayload = { + extractorSha256, + factPackId, + factPackRevision, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + const factPackSha256 = canonicalSha256(factPackPayload); + const factsSha256 = canonicalSha256(facts); + const payload = { + extractorSha256, + factPackId, + factPackRevision, + factPackSha256, + facts, + factsSha256, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, datasetSha256: canonicalSha256(payload) }; +} +function parseOhProjectionDatasetV1(value, snapshot) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "datasetSha256", + "extractorSha256", + "factPackId", + "factPackRevision", + "factPackSha256", + "facts", + "factsSha256", + "snapshotSha256", + "v" + ]) || value.v !== 1 || !Array.isArray(value.facts)) + return null; + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const declaredFactPackSha256 = parseSha256Hex(value.factPackSha256); + const declaredFactsSha256 = parseSha256Hex(value.factsSha256); + try { + const dataset = createOhProjectionDatasetV1({ + extractorSha256: value.extractorSha256, + factPackId: value.factPackId, + factPackRevision: value.factPackRevision, + facts: value.facts, + snapshot + }); + return datasetSha256 !== null && declaredFactPackSha256 === dataset.factPackSha256 && declaredFactsSha256 === dataset.factsSha256 && value.snapshotSha256 === dataset.snapshotSha256 && dataset.datasetSha256 === datasetSha256 ? dataset : null; + } catch { + return null; + } +} +function ohProjectionVariableV1(name) { + const parsed = projectionName(name); + if (parsed === null) + throw new TypeError("Invalid projection variable name."); + return { kind: "variable", name: parsed, v: 1 }; +} +function ohProjectionConstantV1(value) { + const parsed = atom(value); + if (parsed === INVALID_PROJECTION_ATOM) + throw new TypeError("Invalid projection constant."); + return { kind: "constant", v: 1, value: parsed }; +} +function createOhProjectionLiteralV1(input) { + const relation = projectionName(input.relation); + if (relation === null || input.terms.length < 1 || input.terms.length > OH_PROJECTION_LIMITS_V1.arity) { + throw new TypeError("Invalid projection literal."); + } + const terms = input.terms.map((term) => parseOhProjectionTermV1(term)); + if (terms.some((term) => term === null)) + throw new TypeError("Invalid term in projection literal."); + return { relation, terms, v: 1 }; +} +function parseOhProjectionTermV1(value) { + if (!isPlainRecord(value) || value.v !== 1) + return null; + if (value.kind === "variable" && hasExactKeys(value, ["kind", "name", "v"])) { + const name = projectionName(value.name); + return name === null ? null : { kind: "variable", name, v: 1 }; + } + if (value.kind === "constant" && hasExactKeys(value, ["kind", "v", "value"])) { + const parsed = atom(value.value); + return parsed === INVALID_PROJECTION_ATOM ? null : { kind: "constant", v: 1, value: parsed }; + } + return null; +} +function parseOhProjectionLiteralV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["relation", "terms", "v"]) || value.v !== 1 || !Array.isArray(value.terms)) + return null; + try { + return createOhProjectionLiteralV1({ + relation: value.relation, + terms: value.terms + }); + } catch { + return null; + } +} +function literalVariables(literal) { + return literal.terms.flatMap((term) => term.kind === "variable" ? [term.name] : []); +} +function createOhProjectionRuleV1(input) { + const ruleId = projectionName(input.ruleId); + const head = parseOhProjectionLiteralV1(input.head); + if (ruleId === null || head === null || input.body.length < 1 || input.body.length > OH_PROJECTION_LIMITS_V1.literalsPerRule) + throw new TypeError("Invalid projection rule."); + const body = input.body.map(parseOhProjectionLiteralV1); + if (body.some((literal) => literal === null)) + throw new TypeError("Invalid body literal in projection rule."); + const bound = new Set(body.flatMap(literalVariables)); + if (literalVariables(head).some((variable) => !bound.has(variable)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projection rule head variable must be bound in its body."); + } + const payload = { body, head, ruleId, v: 1 }; + return { ...payload, ruleSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRuleV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["body", "head", "ruleId", "ruleSha256", "v"]) || value.v !== 1 || !Array.isArray(value.body)) + return null; + const ruleSha256 = parseSha256Hex(value.ruleSha256); + try { + const rule = createOhProjectionRuleV1({ + body: value.body, + head: value.head, + ruleId: value.ruleId + }); + return ruleSha256 !== null && rule.ruleSha256 === ruleSha256 ? rule : null; + } catch { + return null; + } +} +function createOhProjectionRulePackV1(input) { + const rulePackId = projectionName(input.rulePackId); + const rulePackRevision = positiveInteger(input.rulePackRevision); + if (rulePackId === null || rulePackRevision === null || input.rules.length < 1 || input.rules.length > OH_PROJECTION_LIMITS_V1.rules) + throw new TypeError("Invalid projection rule pack."); + const parsedRules = input.rules.map(parseOhProjectionRuleV1); + if (parsedRules.some((rule) => rule === null)) + throw new TypeError("Invalid rule in projection rule pack."); + const rules = sortUnique(parsedRules, (rule) => rule.ruleId); + const rulesSha256 = canonicalSha256(rules); + const payload = { + rulePackId, + rulePackRevision, + rules, + rulesSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + return { ...payload, rulePackSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRulePackV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "rulePackId", + "rulePackRevision", + "rulePackSha256", + "rules", + "rulesSha256", + "semantics", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1 || !Array.isArray(value.rules)) + return null; + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const rulesSha256 = parseSha256Hex(value.rulesSha256); + try { + const pack = createOhProjectionRulePackV1({ + rulePackId: value.rulePackId, + rulePackRevision: value.rulePackRevision, + rules: value.rules + }); + return rulePackSha256 === pack.rulePackSha256 && rulesSha256 === pack.rulesSha256 ? pack : null; + } catch { + return null; + } +} +function createOhProjectionQueryV1(input) { + const queryId = projectionName(input.queryId); + const limit = positiveInteger(input.limit ?? 1000, OH_PROJECTION_LIMITS_V1.queryResults); + if (queryId === null || limit === null || input.find.length < 1 || input.find.length > OH_PROJECTION_LIMITS_V1.arity || input.where.length < 1 || input.where.length > OH_PROJECTION_LIMITS_V1.queryLiterals) + throw new TypeError("Invalid projection query."); + const find = input.find.map((name) => projectionName(name)); + const where = input.where.map(parseOhProjectionLiteralV1); + if (find.some((name) => name === null) || !orderedUnique([...find].sort(), String) || where.some((literal) => literal === null)) + throw new TypeError("Invalid projection query variables or literals."); + const bound = new Set(where.flatMap(literalVariables)); + if (find.some((name) => !bound.has(name)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projected query variable must be bound in the query body."); + } + const payload = { + find, + limit, + queryId, + where, + v: 1 + }; + return { ...payload, querySha256: canonicalSha256(payload) }; +} +function parseOhProjectionQueryV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["find", "limit", "queryId", "querySha256", "where", "v"]) || value.v !== 1 || !Array.isArray(value.find) || !Array.isArray(value.where)) + return null; + const querySha256 = parseSha256Hex(value.querySha256); + try { + const query = createOhProjectionQueryV1({ + find: value.find, + limit: value.limit, + queryId: value.queryId, + where: value.where + }); + return querySha256 !== null && query.querySha256 === querySha256 ? query : null; + } catch { + return null; + } +} +function createOhProjectionIdentityV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const query = parseOhProjectionQueryV1(input.query); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + if (snapshot === null || dataset === null || query === null || rulePack === null) { + throw new TypeError("Invalid projection identity input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + datasetSha256: dataset.datasetSha256, + querySha256: query.querySha256, + rulePackSha256: rulePack.rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, projectionSha256: canonicalSha256(payload) }; +} +function parseOhProjectionIdentityV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "datasetSha256", + "projectionSha256", + "querySha256", + "rulePackSha256", + "semantics", + "snapshotSha256", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const projectionSha256 = parseSha256Hex(value.projectionSha256); + const querySha256 = parseSha256Hex(value.querySha256); + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) + return null; + const payload = { + contractSha256, + datasetSha256, + querySha256, + rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256, + v: 1 + }; + return canonicalSha256(payload) === projectionSha256 ? { ...payload, projectionSha256 } : null; +} +function invalidationForOhProjectionV1(previous, next) { + const parsedPrevious = parseOhProjectionIdentityV1(previous); + const parsedNext = parseOhProjectionIdentityV1(next); + if (parsedPrevious === null || parsedNext === null) + throw new TypeError("Invalid projection identity."); + if (parsedPrevious.projectionSha256 === parsedNext.projectionSha256) + return { kind: "reusable", v: 1 }; + const reasons = []; + if (parsedPrevious.snapshotSha256 !== parsedNext.snapshotSha256) + reasons.push("snapshot-changed"); + if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) + reasons.push("dataset-changed"); + if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) + reasons.push("rule-pack-changed"); + if (parsedPrevious.querySha256 !== parsedNext.querySha256) + reasons.push("query-changed"); + return { kind: "full-rebuild", reasons, v: 1 }; +} +function tupleKey(value) { + return canonicalJson(value); +} +function referenceKey(reference) { + return canonicalJson([reference.relation, reference.tuple]); +} +function relationTuples(relations, relation) { + return [...relations.get(relation)?.values() ?? []].sort((left, right) => compareCanonical(left.tuple, right.tuple)); +} +function setArity(arities, relation, arity) { + const existing = arities.get(relation); + if (existing !== undefined && existing !== arity) { + throw new TypeError(`Projection relation ${relation} is used with conflicting arities.`); + } + arities.set(relation, arity); + if (arities.size > OH_PROJECTION_LIMITS_V1.relations) + throw new RangeError("Projection uses too many relations."); +} +function validateProgramArities(dataset, rulePack, query) { + const arities = new Map; + for (const fact of dataset.facts) + setArity(arities, fact.relation, fact.tuple.length); + for (const rule of rulePack.rules) { + setArity(arities, rule.head.relation, rule.head.terms.length); + for (const literal of rule.body) + setArity(arities, literal.relation, literal.terms.length); + } + for (const literal of query.where) + setArity(arities, literal.relation, literal.terms.length); +} +function sameAtom(left, right) { + return left === right; +} +function unifyLiteral(literal, state, binding) { + const next = new Map(binding); + for (let index = 0;index < literal.terms.length; index += 1) { + const term = literal.terms[index]; + const value = state.tuple[index]; + if (term.kind === "constant") { + if (!sameAtom(term.value, value)) + return null; + continue; + } + if (next.has(term.name)) { + if (!sameAtom(next.get(term.name), value)) + return null; + } else + next.set(term.name, value); + } + return next; +} +function matchBody(relations, body, maximumMatches) { + let matches = [{ binding: new Map, premises: [] }]; + for (const literal of body) { + const next = []; + const candidates = relationTuples(relations, literal.relation); + for (const match of matches) { + for (const candidate of candidates) { + const binding = unifyLiteral(literal, candidate, match.binding); + if (binding === null) + continue; + next.push({ binding, premises: [...match.premises, { + relation: literal.relation, + tuple: candidate.tuple + }] }); + if (next.length > maximumMatches) + throw new RangeError("Projection join exceeds its match bound."); + } + } + matches = next; + if (matches.length === 0) + break; + } + return matches; +} +function instantiateHead(head, binding) { + return head.terms.map((term) => term.kind === "constant" ? term.value : binding.get(term.name)); +} +function canonicalWitness(witness) { + if (witness.kind === "fact") + return canonicalJson(witness); + return canonicalJson({ kind: witness.kind, premises: witness.premises, ruleSha256: witness.rule.ruleSha256 }); +} +function materializeNaive(input) { + const relations = new Map; + for (const fact of input.dataset.facts) { + let relation = relations.get(fact.relation); + if (relation === undefined) { + relation = new Map; + relations.set(fact.relation, relation); + } + relation.set(tupleKey(fact.tuple), { tuple: fact.tuple, witness: { kind: "fact", sources: fact.sources } }); + } + let derivedFacts = 0; + let rounds = 0; + while (true) { + const candidates = new Map; + for (const rule of input.rulePack.rules) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + const derivedTuple = instantiateHead(rule.head, match.binding); + const relation = relations.get(rule.head.relation); + const key = tupleKey(derivedTuple); + if (relation?.has(key) === true) + continue; + const state = { + tuple: derivedTuple, + witness: { kind: "derived", premises: match.premises, rule } + }; + const identity = referenceKey({ relation: rule.head.relation, tuple: derivedTuple }); + const existing = candidates.get(identity); + if (existing === undefined || canonicalWitness(state.witness) < canonicalWitness(existing.state.witness)) { + candidates.set(identity, { relation: rule.head.relation, state }); + } + } + } + if (candidates.size === 0) + break; + if (rounds >= input.maximumRounds) + throw new RangeError("Projection exceeds its evaluation round bound."); + if (derivedFacts + candidates.size > input.maximumDerivedTuples) { + throw new RangeError("Projection exceeds its derived tuple bound."); + } + const ordered = [...candidates.values()].sort((left, right) => compareCanonical([left.relation, left.state.tuple], [right.relation, right.state.tuple])); + for (const candidate of ordered) { + let relation = relations.get(candidate.relation); + if (relation === undefined) { + relation = new Map; + relations.set(candidate.relation, relation); + } + relation.set(tupleKey(candidate.state.tuple), candidate.state); + } + derivedFacts += candidates.size; + rounds += 1; + } + return { baseFacts: input.dataset.facts.length, derivedFacts, relations, rounds }; +} +function boundedOption(value, fallback, maximum, label) { + const parsed = positiveInteger(value ?? fallback, maximum); + if (parsed === null) + throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`); + return parsed; +} +function resolveEvaluationOptions(options) { + return { + maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), + maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), + maximumProofNodes: boundedOption(options.maximumProofNodes, 1024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds") + }; +} +function proofForReference(relations, reference, budget, options, depth, visiting) { + if (budget.nodes >= options.maximumProofNodes) + return null; + if (budget.nodes === options.maximumProofNodes - 1) { + budget.nodes += 1; + return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + budget.nodes += 1; + if (depth >= options.maximumProofDepth) { + return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const identity = referenceKey(reference); + if (visiting.has(identity)) { + return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); + if (state === undefined) + throw new Error("Projection proof references a tuple outside the materialized result."); + if (state.witness.kind === "fact") { + return { + kind: "fact", + relation: reference.relation, + sources: state.witness.sources, + tuple: reference.tuple, + v: 1 + }; + } + visiting.add(identity); + try { + const premises = []; + for (const premise of state.witness.premises) { + const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); + if (proof === null) + break; + premises.push(proof); + } + return { + kind: "derived", + premises, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + } finally { + visiting.delete(identity); + } +} +function buildProjectionResult(input) { + const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches); + const byValues = new Map; + for (const match of matches) { + const values = input.query.find.map((name) => match.binding.get(name)); + const key = tupleKey(values); + const existing = byValues.get(key); + if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) + byValues.set(key, match); + } + const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + const truncated = ordered.length > input.query.limit; + const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const values = JSON.parse(key); + const budget = { nodes: 0 }; + const proofs = []; + for (const premise of match.premises) { + const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set); + if (proof === null) + break; + proofs.push(proof); + } + return { proofs, values, v: 1 }; + }); + const identity = createOhProjectionIdentityV1({ + dataset: input.dataset, + query: input.query, + rulePack: input.rulePack, + snapshot: input.snapshot + }); + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine: input.engine, + evaluation: { ...input.options, v: 1 }, + identity, + rows, + stats: { + baseFacts: input.materialized.baseFacts, + derivedFacts: input.materialized.derivedFacts, + queryMatches: matches.length, + relations: input.materialized.relations.size, + rounds: input.materialized.rounds, + truncated, + v: 1 + }, + v: 1 + }; + return { ...payload, resultSha256: canonicalSha256(payload) }; +} +function evaluateOhProjectionV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + if (snapshot === null || dataset === null || rulePack === null || query === null) { + throw new TypeError("Invalid projection snapshot, dataset, rule pack, or query."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const materialized = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack + }); + return buildProjectionResult({ + dataset, + engine: OH_PROJECTION_INTERNAL_ENGINE_V1, + materialized, + options, + query, + rulePack, + snapshot + }); +} +function evaluateOhProjectionWithMaterializerV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + const engine = safeCode(input.engine, 256); + if (snapshot === null || dataset === null || rulePack === null || query === null || engine === null) { + throw new TypeError("Invalid projection adapter input."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const external = input.materialize({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + query, + rulePack + }); + const witnessMaterialization = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack + }); + const externalCanonical = new Map; + for (const [relationName, tuples] of external.relationFacts) { + const relation = projectionName(relationName); + if (relation === null || tuples.length > OH_PROJECTION_LIMITS_V1.facts + options.maximumDerivedTuples) { + throw new TypeError("Projection adapter returned an invalid relation."); + } + const parsed = tuples.map(tuple); + if (parsed.some((value) => value === null)) + throw new TypeError("Projection adapter returned an invalid tuple."); + const keys = []; + for (const value of parsed) { + if (value === null) + throw new TypeError("Projection adapter returned an invalid tuple."); + keys.push(tupleKey(value)); + } + externalCanonical.set(relation, [...new Set(keys)].sort()); + } + const expectedCanonical = new Map([...witnessMaterialization.relations.entries()].map(([relation, states]) => [relation, [...states.values()].map((state) => tupleKey(state.tuple)).sort()])); + const relationNames = [...new Set([...externalCanonical.keys(), ...expectedCanonical.keys()])].sort(); + for (const relation of relationNames) { + if (canonicalJson(externalCanonical.get(relation) ?? []) !== canonicalJson(expectedCanonical.get(relation) ?? [])) { + throw new Error(`Projection adapter disagrees with Oh semantics for relation ${relation}.`); + } + } + return buildProjectionResult({ + dataset, + engine, + materialized: witnessMaterialization, + options, + query, + rulePack, + snapshot + }); +} +function createOhProjectionRecordFactsV1(records, options = {}) { + if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + throw new RangeError("Too many records for projection facts."); + const facts = []; + for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { + const record = parseKnowledgeGraphRecordV1(candidate); + if (record === null) + throw new TypeError("Invalid graph record for projection facts."); + const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 }]; + if (options.includeRecords !== false) { + facts.push(createOhProjectionFactV1({ + relation: "oh.record", + sources: source, + tuple: [record.key, record.kind, record.recordSha256] + })); + } + if (options.includeDependencies !== false) { + for (const dependency of record.dependencies) { + facts.push(createOhProjectionFactV1({ + relation: "oh.dependency", + sources: source, + tuple: [record.key, dependency] + })); + } + } + } + return facts.sort(compareProjectionFacts); +} +function isOhProjectionRecordKindV1(value) { + return OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === value); +} + +// src/projection-suss.ts +var OH_PROJECTION_SUSS_VERSION_V1 = "0.20.0"; +var OH_PROJECTION_SUSS_ENGINE_V1 = "suss.datalog.v0-20-0.equivalence"; +function encodeAtom(value) { + return canonicalJson(value); +} +function decodeAtom(value) { + if (typeof value !== "string") + throw new TypeError("Suss returned a non-encoded projection atom."); + const decoded = parseCanonicalJson(value, 16 * 1024); + if (decoded !== null && typeof decoded === "object") { + throw new TypeError("Suss returned a non-atomic projection value."); + } + return decoded; +} +function sussTerm(term) { + return term.kind === "constant" ? constant(encodeAtom(term.value)) : variable(term.name); +} +function sussLiteral(literal) { + return lit(literal.relation, ...literal.terms.map(sussTerm)); +} +function sussRule(input) { + return rule(input.head.relation, input.head.terms.map(sussTerm), input.body.map(sussLiteral), input.ruleId); +} +function projectionDomainSize(dataset, rulePack, query) { + const atoms = new Set; + for (const fact of dataset.facts) + for (const value of fact.tuple) + atoms.add(encodeAtom(value)); + const addTerms = (literal) => { + for (const term of literal.terms) + if (term.kind === "constant") + atoms.add(encodeAtom(term.value)); + }; + for (const item of rulePack.rules) { + addTerms(item.head); + for (const literal of item.body) + addTerms(literal); + } + for (const literal of query.where) + addTerms(literal); + return atoms.size; +} +function assertConservativeOutputBound(input) { + const domainSize = BigInt(projectionDomainSize(input.dataset, input.rulePack, input.query)); + const heads = new Map; + for (const item of input.rulePack.rules) + heads.set(item.head.relation, item.head.terms.length); + let possible = 0n; + const maximum = BigInt(input.maximumDerivedTuples + input.dataset.facts.length); + for (const arity of heads.values()) { + possible += domainSize ** BigInt(arity); + if (possible > maximum) { + throw new RangeError("The Suss equivalence adapter cannot prove the requested derived-tuple bound before evaluation; use the bounded Oh evaluator."); + } + } +} +function evaluateOhProjectionWithSussV1(input) { + return evaluateOhProjectionWithMaterializerV1({ + dataset: input.dataset, + engine: OH_PROJECTION_SUSS_ENGINE_V1, + ...input.options === undefined ? {} : { options: input.options }, + query: input.query, + rulePack: input.rulePack, + snapshot: input.snapshot, + materialize: (program) => { + assertConservativeOutputBound(program); + const database = new Database; + for (const fact of program.dataset.facts) { + database.add(fact.relation, fact.tuple.map(encodeAtom)); + } + evaluate(database, program.rulePack.rules.map(sussRule)); + const relationFacts = new Map; + for (const relation of database.relationNames()) { + relationFacts.set(relation, database.facts(relation).map((values) => values.map(decodeAtom))); + } + return { relationFacts }; + } + }); +} +export { + evaluateOhProjectionWithSussV1, + OH_PROJECTION_SUSS_VERSION_V1, + OH_PROJECTION_SUSS_ENGINE_V1 +}; diff --git a/dist/projection.d.ts b/dist/projection.d.ts new file mode 100644 index 0000000..82e05cc --- /dev/null +++ b/dist/projection.d.ts @@ -0,0 +1,282 @@ +import { type JsonPrimitive, type Sha256Hex } from "./canonical"; +import { type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordRefV1, type KnowledgeGraphRecordV1 } from "./graph"; +export declare const OH_PROJECTION_FORMAT_VERSION_V1: 1; +export declare const OH_PROJECTION_SEMANTICS_V1: "oh.projection.positive-datalog.v1"; +export declare const OH_PROJECTION_INTERNAL_ENGINE_V1: "oh.naive.positive.v1"; +export declare const OH_PROJECTION_LIMITS_V1: Readonly<{ + arity: 32; + atomBytes: number; + derivedTuples: 262144; + facts: 262144; + literalsPerRule: 64; + proofDepth: 128; + proofNodes: 4096; + queryLiterals: 64; + queryMatches: 262144; + queryResults: 65536; + relations: 4096; + rounds: 1024; + rules: 1024; + sourcesPerFact: 64; + variables: 256; +}>; +export type OhProjectionAtomV1 = JsonPrimitive; +export type OhProjectionSnapshotV1 = Readonly<{ + contractSha256: Sha256Hex; + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordRefs: readonly KnowledgeGraphRecordRefV1[]; + recordsSha256: Sha256Hex; + sequence: number; + snapshotSha256: Sha256Hex; + spaceId: string; + v: 1; +}>; +export type OhProjectionFactSourceV1 = Readonly<{ + key: string; + recordSha256: Sha256Hex; + v: 1; +}>; +export type OhProjectionFactV1 = Readonly<{ + factSha256: Sha256Hex; + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}>; +export type OhProjectionDatasetV1 = Readonly<{ + datasetSha256: Sha256Hex; + extractorSha256: Sha256Hex; + factPackId: string; + factPackRevision: number; + factPackSha256: Sha256Hex; + facts: readonly OhProjectionFactV1[]; + factsSha256: Sha256Hex; + snapshotSha256: Sha256Hex; + v: 1; +}>; +export declare const OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1: Readonly<{ + extractorSha256: Sha256Hex; + factPackId: "oh.record-facts"; + factPackRevision: 1; + relations: readonly ["oh.dependency", "oh.record"]; + semantics: "oh.projection.positive-datalog.v1"; + v: 1; +}>; +export type OhProjectionTermV1 = Readonly<{ + kind: "constant"; + v: 1; + value: OhProjectionAtomV1; +}> | Readonly<{ + kind: "variable"; + name: string; + v: 1; +}>; +export type OhProjectionLiteralV1 = Readonly<{ + relation: string; + terms: readonly OhProjectionTermV1[]; + v: 1; +}>; +export type OhProjectionRuleV1 = Readonly<{ + body: readonly OhProjectionLiteralV1[]; + head: OhProjectionLiteralV1; + ruleId: string; + ruleSha256: Sha256Hex; + v: 1; +}>; +export type OhProjectionRulePackV1 = Readonly<{ + rulePackId: string; + rulePackRevision: number; + rulePackSha256: Sha256Hex; + rules: readonly OhProjectionRuleV1[]; + rulesSha256: Sha256Hex; + semantics: typeof OH_PROJECTION_SEMANTICS_V1; + v: 1; +}>; +export type OhProjectionQueryV1 = Readonly<{ + find: readonly string[]; + limit: number; + queryId: string; + querySha256: Sha256Hex; + where: readonly OhProjectionLiteralV1[]; + v: 1; +}>; +export type OhProjectionIdentityV1 = Readonly<{ + contractSha256: Sha256Hex; + datasetSha256: Sha256Hex; + projectionSha256: Sha256Hex; + querySha256: Sha256Hex; + rulePackSha256: Sha256Hex; + semantics: typeof OH_PROJECTION_SEMANTICS_V1; + snapshotSha256: Sha256Hex; + v: 1; +}>; +export type OhProjectionProofV1 = Readonly<{ + kind: "fact"; + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}> | Readonly<{ + kind: "derived"; + premises: readonly OhProjectionProofV1[]; + relation: string; + ruleId: string; + ruleSha256: Sha256Hex; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}> | Readonly<{ + kind: "truncated"; + reason: "cycle" | "depth" | "nodes"; + relation: string; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}>; +export type OhProjectionResultRowV1 = Readonly<{ + proofs: readonly OhProjectionProofV1[]; + values: readonly OhProjectionAtomV1[]; + v: 1; +}>; +export type OhProjectionResultV1 = Readonly<{ + authority: "derived"; + cache: Readonly<{ + strategy: "full-rebuild"; + v: 1; + }>; + engine: string; + evaluation: Readonly<{ + maximumDerivedTuples: number; + maximumProofDepth: number; + maximumProofNodes: number; + maximumRounds: number; + v: 1; + }>; + identity: OhProjectionIdentityV1; + resultSha256: Sha256Hex; + rows: readonly OhProjectionResultRowV1[]; + stats: Readonly<{ + baseFacts: number; + derivedFacts: number; + queryMatches: number; + relations: number; + rounds: number; + truncated: boolean; + v: 1; + }>; + v: 1; +}>; +export type OhProjectionEvaluationOptionsV1 = Readonly<{ + maximumDerivedTuples?: number; + maximumProofDepth?: number; + maximumProofNodes?: number; + maximumRounds?: number; +}>; +export type OhProjectionInvalidationReasonV1 = "dataset-changed" | "query-changed" | "rule-pack-changed" | "snapshot-changed"; +export type OhProjectionInvalidationV1 = Readonly<{ + kind: "reusable"; + v: 1; +}> | Readonly<{ + kind: "full-rebuild"; + reasons: readonly OhProjectionInvalidationReasonV1[]; + v: 1; +}>; +type ProjectionHeadInputV1 = Readonly<{ + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordsSha256: Sha256Hex; + sequence: number; +}>; +export declare function createOhProjectionSnapshotV1(input: Readonly<{ + head: ProjectionHeadInputV1; + records: readonly KnowledgeGraphRecordV1[]; + spaceId: string; +}>): OhProjectionSnapshotV1; +export declare function parseOhProjectionSnapshotV1(value: unknown): OhProjectionSnapshotV1 | null; +export declare function createOhProjectionFactV1(input: Readonly<{ + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; +}>): OhProjectionFactV1; +export declare function parseOhProjectionFactV1(value: unknown): OhProjectionFactV1 | null; +export declare function createOhProjectionDatasetV1(input: Readonly<{ + extractorSha256: Sha256Hex; + factPackId: string; + factPackRevision: number; + facts: readonly OhProjectionFactV1[]; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionDatasetV1; +export declare function parseOhProjectionDatasetV1(value: unknown, snapshot: OhProjectionSnapshotV1): OhProjectionDatasetV1 | null; +export declare function ohProjectionVariableV1(name: string): OhProjectionTermV1; +export declare function ohProjectionConstantV1(value: OhProjectionAtomV1): OhProjectionTermV1; +export declare function createOhProjectionLiteralV1(input: Readonly<{ + relation: string; + terms: readonly OhProjectionTermV1[]; +}>): OhProjectionLiteralV1; +export declare function parseOhProjectionTermV1(value: unknown): OhProjectionTermV1 | null; +export declare function parseOhProjectionLiteralV1(value: unknown): OhProjectionLiteralV1 | null; +export declare function createOhProjectionRuleV1(input: Readonly<{ + body: readonly OhProjectionLiteralV1[]; + head: OhProjectionLiteralV1; + ruleId: string; +}>): OhProjectionRuleV1; +export declare function parseOhProjectionRuleV1(value: unknown): OhProjectionRuleV1 | null; +export declare function createOhProjectionRulePackV1(input: Readonly<{ + rulePackId: string; + rulePackRevision: number; + rules: readonly OhProjectionRuleV1[]; +}>): OhProjectionRulePackV1; +export declare function parseOhProjectionRulePackV1(value: unknown): OhProjectionRulePackV1 | null; +export declare function createOhProjectionQueryV1(input: Readonly<{ + find: readonly string[]; + limit?: number; + queryId: string; + where: readonly OhProjectionLiteralV1[]; +}>): OhProjectionQueryV1; +export declare function parseOhProjectionQueryV1(value: unknown): OhProjectionQueryV1 | null; +export declare function createOhProjectionIdentityV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionIdentityV1; +export declare function parseOhProjectionIdentityV1(value: unknown): OhProjectionIdentityV1 | null; +export declare function invalidationForOhProjectionV1(previous: OhProjectionIdentityV1, next: OhProjectionIdentityV1): OhProjectionInvalidationV1; +export declare function evaluateOhProjectionV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1; +/** + * Internal adapter seam. It is exported for package-owned optional engines, + * not as authority: callers receive the same derived-only result envelope. + */ +export declare function evaluateOhProjectionWithMaterializerV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + engine: string; + materialize: (program: Readonly<{ + dataset: OhProjectionDatasetV1; + maximumDerivedTuples: number; + maximumRounds: number; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + }>) => Readonly<{ + relationFacts: ReadonlyMap; + }>; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1; +export type OhProjectionRecordFactOptionsV1 = Readonly<{ + includeDependencies?: boolean; + includeRecords?: boolean; +}>; +/** Builds the stable structural fact layer shared by every domain fact pack. */ +export declare function createOhProjectionRecordFactsV1(records: readonly KnowledgeGraphRecordV1[], options?: OhProjectionRecordFactOptionsV1): readonly OhProjectionFactV1[]; +export declare function isOhProjectionRecordKindV1(value: unknown): value is KnowledgeGraphRecordKindV1; +export {}; +//# sourceMappingURL=projection.d.ts.map \ No newline at end of file diff --git a/dist/projection.d.ts.map b/dist/projection.d.ts.map new file mode 100644 index 0000000..9c3469c --- /dev/null +++ b/dist/projection.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/package.json b/package.json index 503d90e..7f6c401 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,14 @@ "types": "./dist/semantic.d.ts", "import": "./dist/semantic.js" }, + "./projection": { + "types": "./dist/projection-public.d.ts", + "import": "./dist/projection-public.js" + }, + "./experimental/projection-suss": { + "types": "./dist/projection-suss.d.ts", + "import": "./dist/projection-suss.js" + }, "./package.json": "./package.json" }, "files": [ @@ -66,24 +74,30 @@ }, "scripts": { "build": "bun run build:js && bun run build:types", - "build:js": "bun build ./src/index.ts ./src/sdk.ts ./src/sqlite/index.ts ./src/sync.ts ./src/semantic.ts ./src/cli.ts --outdir ./dist --target bun --format esm --external bun:sqlite", + "build:js": "bun build ./src/index.ts ./src/sdk.ts ./src/sqlite/index.ts ./src/sync.ts ./src/semantic.ts ./src/projection-public.ts ./src/projection-suss.ts ./src/cli.ts --outdir ./dist --target bun --format esm --external bun:sqlite --external @suss/datalog", "build:types": "tsc -p tsconfig.build.json", - "check": "bun run typecheck && bun run test && bun run build", + "check": "bun run typecheck && bun run test && bun run build && bun run test:node-projection", "test": "bun test ./src ./tests ./site/tests/source.test.ts", + "test:node-projection": "node --test ./scripts/projection-node.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { + "@suss/datalog": "0.20.0", "@types/bun": "1.3.14", "typescript": "5.9.3" }, "peerDependencies": { "@libsql/client": ">=0.17.4 <1", + "@suss/datalog": "0.20.0", "@tobilu/qmd": "2.5.3" }, "peerDependenciesMeta": { "@libsql/client": { "optional": true }, + "@suss/datalog": { + "optional": true + }, "@tobilu/qmd": { "optional": true } diff --git a/scripts/projection-node.test.mjs b/scripts/projection-node.test.mjs new file mode 100644 index 0000000..33d414c --- /dev/null +++ b/scripts/projection-node.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import * as projectionSurface from "../dist/projection-public.js"; + +import { + createOhProjectionDatasetV1, + createOhProjectionLiteralV1, + createOhProjectionQueryV1, + createOhProjectionRulePackV1, + createOhProjectionRuleV1, + createOhProjectionSnapshotV1, + evaluateOhProjectionV1, + ohProjectionVariableV1, +} from "../dist/projection-public.js"; + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +test("the projection subpath runs under Node without loading the SQLite runtime", async () => { + const snapshot = createOhProjectionSnapshotV1({ + head: { generation: 0, graphRevisionSha256: null, operationSha256: null, + recordsSha256: sha256("[]"), sequence: 0 }, + records: [], + spaceId: "node.serverless", + }); + const dataset = createOhProjectionDatasetV1({ extractorSha256: "a".repeat(64), + factPackId: "node.empty", factPackRevision: 1, facts: [], snapshot }); + const x = ohProjectionVariableV1("x"); + const base = createOhProjectionLiteralV1({ relation: "base", terms: [x] }); + const derived = createOhProjectionLiteralV1({ relation: "derived", terms: [x] }); + const rulePack = createOhProjectionRulePackV1({ rulePackId: "node.rules", rulePackRevision: 1, + rules: [createOhProjectionRuleV1({ body: [base], head: derived, ruleId: "derived.from-base" })] }); + const query = createOhProjectionQueryV1({ find: ["x"], queryId: "node.query", where: [derived] }); + const result = evaluateOhProjectionV1({ dataset, query, rulePack, snapshot }); + assert.deepEqual(result.rows, []); + assert.equal(result.authority, "derived"); + assert.equal(Object.hasOwn(projectionSurface, "evaluateOhProjectionWithMaterializerV1"), false); + + const sources = await Promise.all(["projection-public.js", "projection-suss.js"] + .map(async (path) => await readFile(new URL(`../dist/${path}`, import.meta.url), "utf8"))); + assert.equal(sources.some((source) => source.includes("bun:sqlite")), false); +}); diff --git a/site/app/spec/page.tsx b/site/app/spec/page.tsx index 2f73e6f..2ba0403 100644 --- a/site/app/spec/page.tsx +++ b/site/app/spec/page.tsx @@ -43,6 +43,7 @@ const sections = [ ["sqlite", "SQLite"], ["sync", "Sync"], ["semantic", "Semantic search"], + ["projection", "Derived projection"], ["versioning", "Versioning"], ] as const; @@ -206,8 +207,26 @@ export default function Specification() { -
+
07
+
+

Derived projection

+

+ Typed positive rules derive recursive views from one exact + graph head and content-addressed fact pack. Rule, query, input, + and result identities use canonical bytes, while explicit work + limits bound tuples, rounds, joins, and proof trees. +

+

+ Projection rows always remain derived cache output. They do not + become assertions, review decisions, or operation history until + an application submits new records through its authority path. +

+
+
+ +
+
08

Versioning and evolution

diff --git a/site/public/spec/README.md b/site/public/spec/README.md index 0957d95..9e4b1b5 100644 --- a/site/public/spec/README.md +++ b/site/public/spec/README.md @@ -27,6 +27,7 @@ binds these versions: - [SQLite storage](v1/storage.md) - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) +- [Derived projections](v1/projection.md) - [Compatibility and migration](v1/migration.md) Machine-readable V1 artifacts: diff --git a/site/public/spec/v1/projection.md b/site/public/spec/v1/projection.md new file mode 100644 index 0000000..6c3c183 --- /dev/null +++ b/site/public/spec/v1/projection.md @@ -0,0 +1,121 @@ +# Derived projections + +Oh projections are reproducible, non-authoritative views over one exact graph +snapshot. The runtime exposes the same typed rule, query, identity, and result +contract through `@hraness/oh/projection` without changing the V1 graph record, +operation, canonical JSON, or SQLite formats. + +## Authority boundary + +A projection result has `authority: "derived"`. Its tuples and proof trees are +cache output. They MUST NOT be interpreted as graph assertions, review +decisions, accepted knowledge, or operation history. An application that wants +to retain a conclusion MUST create and review new graph records through its +ordinary authority path. + +The projection module imports no SQLite runtime. A caller supplies an exact +snapshot assembled from the authority it selected. This keeps the same contract +usable in a Node 24 serverless process, a local agent, or an application-owned +remote-store adapter. + +## Exact input identity + +`createOhProjectionSnapshotV1` accepts a space ID, current head, and complete +record snapshot. It: + +1. parses every record under the unchanged V1 graph contract; +2. sorts record references by logical key; +3. recomputes `recordsSha256` and requires it to equal the head; +4. checks dependency closure; and +5. hashes the contract digest, space, head, and record references into + `snapshotSha256`. + +A fact is one relation tuple plus one or more exact source record keys and +digests. A dataset rejects a source that is not current at its snapshot. +Duplicate relation tuples are coalesced and retain the union of their source +records. The dataset identity binds: + +- the snapshot digest; +- a fact-pack ID and revision; +- the SHA-256 digest of the application-owned extractor implementation; and +- the canonical ordered fact set. + +Oh supplies structural `oh.record(key, kind, digest)` and +`oh.dependency(key, dependency)` facts. Domain packs may emit richer relations, +but their extractor digest and every source record remain explicit. The +package-owned structural extractor profile is published as +`OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1`. + +## Positive rule semantics + +A term is a variable or a JSON primitive constant. A literal names one relation +and an ordered term list. A rule has one head and a nonempty conjunction of +positive body literals. Every head variable MUST occur in its body. Relation +arity MUST be consistent across base facts, rule heads, rule bodies, and the +query. + +The V1 projection evaluator implements finite, positive Datalog with set +semantics and synchronous naïve fixpoint rounds. It supports recursive rules. +It does not support negation, aggregation, arithmetic, function symbols, or +callbacks inside rules. Unknown objects are parsed with exact keys, so an +unsupported operator fails rather than being ignored. + +Rule packs are sorted by rule ID and content-addressed. A query declares an +ordered `find` variable list, a nonempty positive body, and an output limit. +Query results use set semantics and sort tuples by canonical JSON. Declaration, +fact, and insertion order do not affect rule-pack identity or output bytes. + +## Evaluation limits + +The implementation checks hard ceilings before or during work: + +| Item | Maximum | +| --- | ---: | +| Tuple arity | 32 | +| Canonical bytes per atom | 16 KiB | +| Base facts | 262,144 | +| Derived tuples | 262,144 | +| Rules | 1,024 | +| Body literals per rule | 64 | +| Evaluation rounds | 1,024 | +| Join matches per rule or query body | 262,144 | +| Returned query rows | 65,536 | +| Proof depth | 128 | +| Proof nodes per row | 4,096 | + +A caller may request smaller derived-tuple, round, proof-depth, and proof-node +bounds. Exceeding a work bound fails closed. A query's declared output limit +returns the first canonical tuples and reports `stats.truncated: true` when more +distinct answers exist. + +## Proofs + +Each returned row carries one proof for each query-body match. A fact leaf names +its relation, tuple, and exact source record references. A derived node names +the rule ID and digest and recursively contains its premises. Depth, node, and +cycle guards emit an explicit `truncated` node. A proof establishes how the +bounded evaluator derived a tuple from the supplied bytes; it does not establish +that a proposition is true. + +## Cache invalidation + +`projectionSha256` binds the current contract, snapshot, dataset, rule pack, +query, and positive-Datalog semantics. A cached result is reusable only when +that digest is unchanged. Any snapshot, dataset, rule-pack, or query change has +`kind: "full-rebuild"` and lists the changed identities. V1 does not claim +incremental deletion or cross-snapshot maintenance. + +## Optional Suss equivalence lane + +`@hraness/oh/experimental/projection-suss` supports exactly +`@suss/datalog@0.20.0` as an optional peer. It encodes every JSON primitive atom +into canonical JSON text, evaluates the positive rules with Suss, and compares +every complete relation to the Oh reference semantics. It returns only after +exact set agreement. + +Suss's public evaluator does not expose an execution-budget hook. Before calling +it, the adapter computes a conservative finite-domain upper bound and refuses a +program it cannot prove will remain under the requested derived-tuple ceiling. +It then runs the bounded reference evaluator for equivalence and canonical proof +construction. This lane evaluates compatibility, not performance. Refusal does +not disable the built-in evaluator. diff --git a/spec/README.md b/spec/README.md index 0957d95..9e4b1b5 100644 --- a/spec/README.md +++ b/spec/README.md @@ -27,6 +27,7 @@ binds these versions: - [SQLite storage](v1/storage.md) - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) +- [Derived projections](v1/projection.md) - [Compatibility and migration](v1/migration.md) Machine-readable V1 artifacts: diff --git a/spec/v1/projection.md b/spec/v1/projection.md new file mode 100644 index 0000000..6c3c183 --- /dev/null +++ b/spec/v1/projection.md @@ -0,0 +1,121 @@ +# Derived projections + +Oh projections are reproducible, non-authoritative views over one exact graph +snapshot. The runtime exposes the same typed rule, query, identity, and result +contract through `@hraness/oh/projection` without changing the V1 graph record, +operation, canonical JSON, or SQLite formats. + +## Authority boundary + +A projection result has `authority: "derived"`. Its tuples and proof trees are +cache output. They MUST NOT be interpreted as graph assertions, review +decisions, accepted knowledge, or operation history. An application that wants +to retain a conclusion MUST create and review new graph records through its +ordinary authority path. + +The projection module imports no SQLite runtime. A caller supplies an exact +snapshot assembled from the authority it selected. This keeps the same contract +usable in a Node 24 serverless process, a local agent, or an application-owned +remote-store adapter. + +## Exact input identity + +`createOhProjectionSnapshotV1` accepts a space ID, current head, and complete +record snapshot. It: + +1. parses every record under the unchanged V1 graph contract; +2. sorts record references by logical key; +3. recomputes `recordsSha256` and requires it to equal the head; +4. checks dependency closure; and +5. hashes the contract digest, space, head, and record references into + `snapshotSha256`. + +A fact is one relation tuple plus one or more exact source record keys and +digests. A dataset rejects a source that is not current at its snapshot. +Duplicate relation tuples are coalesced and retain the union of their source +records. The dataset identity binds: + +- the snapshot digest; +- a fact-pack ID and revision; +- the SHA-256 digest of the application-owned extractor implementation; and +- the canonical ordered fact set. + +Oh supplies structural `oh.record(key, kind, digest)` and +`oh.dependency(key, dependency)` facts. Domain packs may emit richer relations, +but their extractor digest and every source record remain explicit. The +package-owned structural extractor profile is published as +`OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1`. + +## Positive rule semantics + +A term is a variable or a JSON primitive constant. A literal names one relation +and an ordered term list. A rule has one head and a nonempty conjunction of +positive body literals. Every head variable MUST occur in its body. Relation +arity MUST be consistent across base facts, rule heads, rule bodies, and the +query. + +The V1 projection evaluator implements finite, positive Datalog with set +semantics and synchronous naïve fixpoint rounds. It supports recursive rules. +It does not support negation, aggregation, arithmetic, function symbols, or +callbacks inside rules. Unknown objects are parsed with exact keys, so an +unsupported operator fails rather than being ignored. + +Rule packs are sorted by rule ID and content-addressed. A query declares an +ordered `find` variable list, a nonempty positive body, and an output limit. +Query results use set semantics and sort tuples by canonical JSON. Declaration, +fact, and insertion order do not affect rule-pack identity or output bytes. + +## Evaluation limits + +The implementation checks hard ceilings before or during work: + +| Item | Maximum | +| --- | ---: | +| Tuple arity | 32 | +| Canonical bytes per atom | 16 KiB | +| Base facts | 262,144 | +| Derived tuples | 262,144 | +| Rules | 1,024 | +| Body literals per rule | 64 | +| Evaluation rounds | 1,024 | +| Join matches per rule or query body | 262,144 | +| Returned query rows | 65,536 | +| Proof depth | 128 | +| Proof nodes per row | 4,096 | + +A caller may request smaller derived-tuple, round, proof-depth, and proof-node +bounds. Exceeding a work bound fails closed. A query's declared output limit +returns the first canonical tuples and reports `stats.truncated: true` when more +distinct answers exist. + +## Proofs + +Each returned row carries one proof for each query-body match. A fact leaf names +its relation, tuple, and exact source record references. A derived node names +the rule ID and digest and recursively contains its premises. Depth, node, and +cycle guards emit an explicit `truncated` node. A proof establishes how the +bounded evaluator derived a tuple from the supplied bytes; it does not establish +that a proposition is true. + +## Cache invalidation + +`projectionSha256` binds the current contract, snapshot, dataset, rule pack, +query, and positive-Datalog semantics. A cached result is reusable only when +that digest is unchanged. Any snapshot, dataset, rule-pack, or query change has +`kind: "full-rebuild"` and lists the changed identities. V1 does not claim +incremental deletion or cross-snapshot maintenance. + +## Optional Suss equivalence lane + +`@hraness/oh/experimental/projection-suss` supports exactly +`@suss/datalog@0.20.0` as an optional peer. It encodes every JSON primitive atom +into canonical JSON text, evaluates the positive rules with Suss, and compares +every complete relation to the Oh reference semantics. It returns only after +exact set agreement. + +Suss's public evaluator does not expose an execution-budget hook. Before calling +it, the adapter computes a conservative finite-domain upper bound and refuses a +program it cannot prove will remain under the requested derived-tuple ceiling. +It then runs the bounded reference evaluator for equivalence and canonical proof +construction. This lane evaluates compatibility, not performance. Refusal does +not disable the built-in evaluator. diff --git a/src/projection-public.ts b/src/projection-public.ts new file mode 100644 index 0000000..ea0720b --- /dev/null +++ b/src/projection-public.ts @@ -0,0 +1,51 @@ +import * as Projection from "./projection"; + +export const OH_PROJECTION_FORMAT_VERSION_V1 = Projection.OH_PROJECTION_FORMAT_VERSION_V1; +export const OH_PROJECTION_INTERNAL_ENGINE_V1 = Projection.OH_PROJECTION_INTERNAL_ENGINE_V1; +export const OH_PROJECTION_LIMITS_V1 = Projection.OH_PROJECTION_LIMITS_V1; +export const OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1 = Projection.OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1; +export const OH_PROJECTION_SEMANTICS_V1 = Projection.OH_PROJECTION_SEMANTICS_V1; +export const createOhProjectionDatasetV1 = Projection.createOhProjectionDatasetV1; +export const createOhProjectionFactV1 = Projection.createOhProjectionFactV1; +export const createOhProjectionIdentityV1 = Projection.createOhProjectionIdentityV1; +export const createOhProjectionLiteralV1 = Projection.createOhProjectionLiteralV1; +export const createOhProjectionQueryV1 = Projection.createOhProjectionQueryV1; +export const createOhProjectionRecordFactsV1 = Projection.createOhProjectionRecordFactsV1; +export const createOhProjectionRulePackV1 = Projection.createOhProjectionRulePackV1; +export const createOhProjectionRuleV1 = Projection.createOhProjectionRuleV1; +export const createOhProjectionSnapshotV1 = Projection.createOhProjectionSnapshotV1; +export const evaluateOhProjectionV1 = Projection.evaluateOhProjectionV1; +export const invalidationForOhProjectionV1 = Projection.invalidationForOhProjectionV1; +export const isOhProjectionRecordKindV1 = Projection.isOhProjectionRecordKindV1; +export const ohProjectionConstantV1 = Projection.ohProjectionConstantV1; +export const ohProjectionVariableV1 = Projection.ohProjectionVariableV1; +export const parseOhProjectionDatasetV1 = Projection.parseOhProjectionDatasetV1; +export const parseOhProjectionFactV1 = Projection.parseOhProjectionFactV1; +export const parseOhProjectionIdentityV1 = Projection.parseOhProjectionIdentityV1; +export const parseOhProjectionLiteralV1 = Projection.parseOhProjectionLiteralV1; +export const parseOhProjectionQueryV1 = Projection.parseOhProjectionQueryV1; +export const parseOhProjectionRulePackV1 = Projection.parseOhProjectionRulePackV1; +export const parseOhProjectionRuleV1 = Projection.parseOhProjectionRuleV1; +export const parseOhProjectionSnapshotV1 = Projection.parseOhProjectionSnapshotV1; +export const parseOhProjectionTermV1 = Projection.parseOhProjectionTermV1; + +export type { + OhProjectionAtomV1, + OhProjectionDatasetV1, + OhProjectionEvaluationOptionsV1, + OhProjectionFactSourceV1, + OhProjectionFactV1, + OhProjectionIdentityV1, + OhProjectionInvalidationReasonV1, + OhProjectionInvalidationV1, + OhProjectionLiteralV1, + OhProjectionProofV1, + OhProjectionQueryV1, + OhProjectionRecordFactOptionsV1, + OhProjectionResultRowV1, + OhProjectionResultV1, + OhProjectionRulePackV1, + OhProjectionRuleV1, + OhProjectionSnapshotV1, + OhProjectionTermV1, +} from "./projection"; diff --git a/src/projection-suss.ts b/src/projection-suss.ts new file mode 100644 index 0000000..170ca90 --- /dev/null +++ b/src/projection-suss.ts @@ -0,0 +1,123 @@ +import { + Database, + constant, + evaluate, + lit, + rule, + variable, + type Rule, + type Term, +} from "@suss/datalog"; + +import { canonicalJson, parseCanonicalJson } from "./canonical"; +import { + evaluateOhProjectionWithMaterializerV1, + type OhProjectionAtomV1, + type OhProjectionDatasetV1, + type OhProjectionEvaluationOptionsV1, + type OhProjectionLiteralV1, + type OhProjectionQueryV1, + type OhProjectionResultV1, + type OhProjectionRulePackV1, + type OhProjectionSnapshotV1, + type OhProjectionTermV1, +} from "./projection"; + +export const OH_PROJECTION_SUSS_VERSION_V1 = "0.20.0" as const; +export const OH_PROJECTION_SUSS_ENGINE_V1 = "suss.datalog.v0-20-0.equivalence" as const; + +function encodeAtom(value: OhProjectionAtomV1): string { + return canonicalJson(value); +} + +function decodeAtom(value: string | number): OhProjectionAtomV1 { + if (typeof value !== "string") throw new TypeError("Suss returned a non-encoded projection atom."); + const decoded = parseCanonicalJson(value, 16 * 1024); + if (decoded !== null && typeof decoded === "object") { + throw new TypeError("Suss returned a non-atomic projection value."); + } + return decoded; +} + +function sussTerm(term: OhProjectionTermV1): Term { + return term.kind === "constant" ? constant(encodeAtom(term.value)) : variable(term.name); +} + +function sussLiteral(literal: OhProjectionLiteralV1) { + return lit(literal.relation, ...literal.terms.map(sussTerm)); +} + +function sussRule(input: OhProjectionRulePackV1["rules"][number]): Rule { + return rule(input.head.relation, input.head.terms.map(sussTerm), + input.body.map(sussLiteral), input.ruleId); +} + +function projectionDomainSize(dataset: OhProjectionDatasetV1, rulePack: OhProjectionRulePackV1, + query: OhProjectionQueryV1): number { + const atoms = new Set(); + for (const fact of dataset.facts) for (const value of fact.tuple) atoms.add(encodeAtom(value)); + const addTerms = (literal: OhProjectionLiteralV1): void => { + for (const term of literal.terms) if (term.kind === "constant") atoms.add(encodeAtom(term.value)); + }; + for (const item of rulePack.rules) { + addTerms(item.head); + for (const literal of item.body) addTerms(literal); + } + for (const literal of query.where) addTerms(literal); + return atoms.size; +} + +function assertConservativeOutputBound(input: Readonly<{ + dataset: OhProjectionDatasetV1; + maximumDerivedTuples: number; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; +}>): void { + const domainSize = BigInt(projectionDomainSize(input.dataset, input.rulePack, input.query)); + const heads = new Map(); + for (const item of input.rulePack.rules) heads.set(item.head.relation, item.head.terms.length); + let possible = 0n; + const maximum = BigInt(input.maximumDerivedTuples + input.dataset.facts.length); + for (const arity of heads.values()) { + possible += domainSize ** BigInt(arity); + if (possible > maximum) { + throw new RangeError("The Suss equivalence adapter cannot prove the requested derived-tuple bound before evaluation; use the bounded Oh evaluator."); + } + } +} + +/** + * Evaluates the positive rule pack with Suss 0.20.0, then requires its complete + * relation sets to equal Oh's bounded reference semantics before returning the + * canonical derived-only result. The conservative admission check keeps Suss, + * whose public evaluator has no execution-budget hook, inside Oh's tuple bound. + */ +export function evaluateOhProjectionWithSussV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1 { + return evaluateOhProjectionWithMaterializerV1({ + dataset: input.dataset, + engine: OH_PROJECTION_SUSS_ENGINE_V1, + ...(input.options === undefined ? {} : { options: input.options }), + query: input.query, + rulePack: input.rulePack, + snapshot: input.snapshot, + materialize: (program) => { + assertConservativeOutputBound(program); + const database = new Database(); + for (const fact of program.dataset.facts) { + database.add(fact.relation, fact.tuple.map(encodeAtom)); + } + evaluate(database, program.rulePack.rules.map(sussRule)); + const relationFacts = new Map(); + for (const relation of database.relationNames()) { + relationFacts.set(relation, database.facts(relation).map((values) => values.map(decodeAtom))); + } + return { relationFacts }; + }, + }); +} diff --git a/src/projection.test.ts b/src/projection.test.ts new file mode 100644 index 0000000..e0aad9f --- /dev/null +++ b/src/projection.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test"; + +import { canonicalJson, canonicalSha256, type Sha256Hex } from "./canonical"; +import { createKnowledgeGraphRecordV1, knowledgeGraphRecordRefV1, + type KnowledgeGraphRecordV1 } from "./graph"; +import { + createOhProjectionDatasetV1, + createOhProjectionFactV1, + createOhProjectionIdentityV1, + createOhProjectionLiteralV1, + createOhProjectionQueryV1, + createOhProjectionRecordFactsV1, + createOhProjectionRulePackV1, + createOhProjectionRuleV1, + createOhProjectionSnapshotV1, + evaluateOhProjectionV1, + invalidationForOhProjectionV1, + OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1, + ohProjectionConstantV1, + ohProjectionVariableV1, + parseOhProjectionQueryV1, + parseOhProjectionIdentityV1, + parseOhProjectionSnapshotV1, + type OhProjectionAtomV1, + type OhProjectionDatasetV1, + type OhProjectionProofV1, + type OhProjectionQueryV1, + type OhProjectionRulePackV1, + type OhProjectionSnapshotV1, +} from "./projection"; +import { evaluateOhProjectionWithSussV1, OH_PROJECTION_SUSS_ENGINE_V1, + OH_PROJECTION_SUSS_VERSION_V1 } from "./projection-suss"; + +const extractorSha256 = "e".repeat(64) as Sha256Hex; + +const v = ohProjectionVariableV1; +const c = ohProjectionConstantV1; +const literal = (relation: string, ...terms: ReturnType[]) => + createOhProjectionLiteralV1({ relation, terms }); + +function edgeRecord(from: string, to: string): KnowledgeGraphRecordV1 { + return createKnowledgeGraphRecordV1({ dependencies: [], key: `view:edge-${from}-${to}`, + kind: "view", v: 1, value: { from, to } }); +} + +function snapshot(records: readonly KnowledgeGraphRecordV1[], sequence = 1): OhProjectionSnapshotV1 { + const refs = [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0) + .map(knowledgeGraphRecordRefV1); + return createOhProjectionSnapshotV1({ + head: { + generation: sequence, + graphRevisionSha256: `${sequence.toString(16).padStart(64, "0")}` as Sha256Hex, + operationSha256: `${(sequence + 100).toString(16).padStart(64, "0")}` as Sha256Hex, + recordsSha256: canonicalSha256(refs), + sequence, + }, + records, + spaceId: "session.test", + }); +} + +function dataset(records: readonly KnowledgeGraphRecordV1[], exactSnapshot: OhProjectionSnapshotV1, + reverse = false): OhProjectionDatasetV1 { + const ordered = reverse ? [...records].reverse() : records; + const facts = ordered.map((record) => createOhProjectionFactV1({ relation: "edge", + sources: [{ key: record.key, recordSha256: record.recordSha256, v: 1 }], + tuple: [(record.value as { from: string }).from, (record.value as { to: string }).to] })); + return createOhProjectionDatasetV1({ extractorSha256, factPackId: "test.edges", + factPackRevision: 1, facts, snapshot: exactSnapshot }); +} + +function reachabilityRules(reverse = false): OhProjectionRulePackV1 { + const x = v("x"); + const y = v("y"); + const z = v("z"); + const direct = createOhProjectionRuleV1({ body: [literal("edge", x, y)], + head: literal("path", x, y), ruleId: "path.direct" }); + const transitive = createOhProjectionRuleV1({ body: [literal("path", x, y), literal("edge", y, z)], + head: literal("path", x, z), ruleId: "path.transitive" }); + return createOhProjectionRulePackV1({ rulePackId: "test.reachability", rulePackRevision: 1, + rules: reverse ? [transitive, direct] : [direct, transitive] }); +} + +function allPathsQuery(limit = 1_000): OhProjectionQueryV1 { + return createOhProjectionQueryV1({ find: ["x", "z"], limit, queryId: "all.paths", + where: [literal("path", v("x"), v("z"))] }); +} + +function countProofNodes(proof: OhProjectionProofV1): number { + return proof.kind === "derived" + ? 1 + proof.premises.reduce((sum, premise) => sum + countProofNodes(premise), 0) + : 1; +} + +function expectedClosure(edges: readonly (readonly [string, string])[]): readonly OhProjectionAtomV1[][] { + const nodes = [...new Set(edges.flat())].sort(); + const reachable = new Set(edges.map((edge) => canonicalJson(edge))); + for (const through of nodes) { + for (const from of nodes) { + for (const to of nodes) { + if (reachable.has(canonicalJson([from, through])) && reachable.has(canonicalJson([through, to]))) { + reachable.add(canonicalJson([from, to])); + } + } + } + } + return [...reachable].sort().map((value) => JSON.parse(value) as OhProjectionAtomV1[]); +} + +describe("projection identity and validation", () => { + test("binds facts to an exact graph head without changing V1 graph bytes", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + expect(parseOhProjectionSnapshotV1(exact)).toEqual(exact); + expect(exact.recordsSha256).toBe(canonicalSha256(records + .map(knowledgeGraphRecordRefV1).sort((left, right) => left.key.localeCompare(right.key)))); + + const facts = dataset(records, exact); + expect(facts.snapshotSha256).toBe(exact.snapshotSha256); + expect(facts.facts).toHaveLength(2); + expect(() => createOhProjectionDatasetV1({ extractorSha256, factPackId: "test.edges", + factPackRevision: 1, facts: [createOhProjectionFactV1({ relation: "edge", + sources: [{ key: records[0]!.key, recordSha256: records[1]!.recordSha256, v: 1 }], + tuple: ["a", "b"] })], snapshot: exact })).toThrow("exact input snapshot"); + + const forged = { ...facts, factsSha256: "f".repeat(64) as Sha256Hex }; + expect(() => evaluateOhProjectionV1({ dataset: forged, query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact })).toThrow("Invalid projection snapshot"); + }); + + test("canonicalizes declaration order and rejects non-positive AST shapes", () => { + const forward = reachabilityRules(); + const reverse = reachabilityRules(true); + expect(reverse).toEqual(forward); + expect(parseOhProjectionQueryV1({ ...allPathsQuery(), where: [{ + ...allPathsQuery().where[0], negated: true, + }] })).toBeNull(); + expect(() => createOhProjectionRuleV1({ body: [literal("edge", v("x"), v("y"))], + head: literal("path", v("x"), v("unbound")), ruleId: "unsafe" })).toThrow("must be bound"); + }); + + test("keeps every JSON primitive atom unambiguous", () => { + const record = edgeRecord("a", "b"); + const exact = snapshot([record]); + const fact = createOhProjectionFactV1({ relation: "primitive", + sources: [{ key: record.key, recordSha256: record.recordSha256, v: 1 }], + tuple: [null, false, 0, "null"] }); + const primitives = createOhProjectionDatasetV1({ extractorSha256, factPackId: "test.primitives", + factPackRevision: 1, facts: [fact], snapshot: exact }); + const query = createOhProjectionQueryV1({ find: ["n", "b", "z", "s"], queryId: "all.primitives", + where: [createOhProjectionLiteralV1({ relation: "primitive", + terms: [v("n"), v("b"), v("z"), v("s")] })] }); + const passthrough = createOhProjectionRulePackV1({ rulePackId: "test.primitives", + rulePackRevision: 1, rules: [createOhProjectionRuleV1({ body: query.where, + head: createOhProjectionLiteralV1({ relation: "copy", + terms: [v("n"), v("b"), v("z"), v("s")] }), ruleId: "copy.primitives" })] }); + const input = { dataset: primitives, query, rulePack: passthrough, snapshot: exact }; + const internal = evaluateOhProjectionV1(input); + expect(internal.rows[0]?.values).toEqual([null, false, 0, "null"]); + expect(evaluateOhProjectionWithSussV1(input).rows).toEqual(internal.rows); + }); + + test("coalesces duplicate tuples while retaining every exact source", () => { + const records = [edgeRecord("a", "b"), createKnowledgeGraphRecordV1({ dependencies: [], + key: "view:edge-a-b-second", kind: "view", v: 1, value: { from: "a", to: "b" } })]; + const exact = snapshot(records); + const facts = records.map((record) => createOhProjectionFactV1({ relation: "edge", + sources: [{ key: record.key, recordSha256: record.recordSha256, v: 1 }], tuple: ["a", "b"] })); + const merged = createOhProjectionDatasetV1({ extractorSha256, factPackId: "test.edges", + factPackRevision: 1, facts, snapshot: exact }); + expect(merged.facts).toHaveLength(1); + expect(merged.facts[0]?.sources).toHaveLength(2); + }); + + test("marks every identity change as an explicit full rebuild", () => { + const records = [edgeRecord("a", "b")]; + const firstSnapshot = snapshot(records, 1); + const secondSnapshot = snapshot(records, 2); + const firstDataset = dataset(records, firstSnapshot); + const secondDataset = dataset(records, secondSnapshot); + const rules = reachabilityRules(); + const query = allPathsQuery(); + const first = createOhProjectionIdentityV1({ dataset: firstDataset, query, rulePack: rules, + snapshot: firstSnapshot }); + expect(parseOhProjectionIdentityV1(first)).toEqual(first); + expect(parseOhProjectionIdentityV1({ ...first, querySha256: "f".repeat(64) })).toBeNull(); + expect(invalidationForOhProjectionV1(first, first)).toEqual({ kind: "reusable", v: 1 }); + const second = createOhProjectionIdentityV1({ dataset: secondDataset, query, rulePack: rules, + snapshot: secondSnapshot }); + expect(invalidationForOhProjectionV1(first, second)).toEqual({ kind: "full-rebuild", + reasons: ["snapshot-changed", "dataset-changed"], v: 1 }); + const changedQuery = createOhProjectionQueryV1({ ...query, limit: 1 }); + const third = createOhProjectionIdentityV1({ dataset: firstDataset, query: changedQuery, + rulePack: rules, snapshot: firstSnapshot }); + expect(invalidationForOhProjectionV1(first, third)).toEqual({ kind: "full-rebuild", + reasons: ["query-changed"], v: 1 }); + }); +}); + +describe("positive recursive evaluation", () => { + test("computes a deterministic fixpoint and bounded proof trees", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c"), edgeRecord("c", "d")]; + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact, true), + options: { maximumProofNodes: 7 }, query: createOhProjectionQueryV1({ find: ["z"], + queryId: "paths.from-a", where: [createOhProjectionLiteralV1({ relation: "path", + terms: [c("a"), v("z")] })] }), rulePack: reachabilityRules(true), snapshot: exact }); + expect(result.authority).toBe("derived"); + expect(result.cache.strategy).toBe("full-rebuild"); + expect(result.rows.map((row) => row.values)).toEqual([["b"], ["c"], ["d"]]); + expect(result.stats).toMatchObject({ baseFacts: 3, derivedFacts: 6, rounds: 3, + truncated: false }); + expect(result.rows.every((row) => row.proofs.reduce((sum, proof) => sum + countProofNodes(proof), 0) <= 7)) + .toBe(true); + expect(canonicalSha256((({ resultSha256: _digest, ...payload }) => payload)(result))) + .toBe(result.resultSha256); + }); + + test("fails closed at evaluation and arity bounds", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + expect(() => evaluateOhProjectionV1({ dataset: dataset(records, exact), + options: { maximumDerivedTuples: 1 }, query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact })).toThrow("derived tuple bound"); + const badQuery = createOhProjectionQueryV1({ find: ["x"], queryId: "bad.arity", + where: [literal("path", v("x"))] }); + expect(() => evaluateOhProjectionV1({ dataset: dataset(records, exact), query: badQuery, + rulePack: reachabilityRules(), snapshot: exact })).toThrow("conflicting arities"); + }); + + test("matches graph reachability across generated input orders", () => { + for (let seed = 1; seed <= 40; seed += 1) { + const edges: [string, string][] = []; + for (let from = 0; from < 7; from += 1) { + for (let to = from + 1; to < 7; to += 1) { + if (((seed * 31 + from * 17 + to * 13) % 5) < 2) edges.push([`n${from}`, `n${to}`]); + } + } + if (edges.length === 0) edges.push(["n0", "n1"]); + const records = edges.map(([from, to]) => edgeRecord(from, to)); + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact, seed % 2 === 0), + query: allPathsQuery(), rulePack: reachabilityRules(seed % 3 === 0), snapshot: exact }); + expect(result.rows.map((row) => row.values)).toEqual([...expectedClosure(edges)]); + } + }); + + test("exposes stable structural record and dependency facts", () => { + const parent = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:parent", + kind: "entity", v: 1, value: { name: "parent" } }); + const child = createKnowledgeGraphRecordV1({ dependencies: [parent.key], key: "statement:child", + kind: "statement", v: 1, value: { name: "child" } }); + expect(OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1).toMatchObject({ factPackId: "oh.record-facts", + factPackRevision: 1 }); + expect(createOhProjectionRecordFactsV1([child, parent]).map((fact) => [fact.relation, fact.tuple])) + .toEqual([ + ["oh.dependency", ["statement:child", "entity:parent"]], + ["oh.record", ["entity:parent", "entity", parent.recordSha256]], + ["oh.record", ["statement:child", "statement", child.recordSha256]], + ]); + }); +}); + +describe("optional Suss equivalence adapter", () => { + test("pins the evaluated package and agrees on complete relation sets", () => { + expect(OH_PROJECTION_SUSS_VERSION_V1).toBe("0.20.0"); + const records = [edgeRecord("a", "b"), edgeRecord("b", "c"), edgeRecord("a", "d")]; + const exact = snapshot(records); + const input = { dataset: dataset(records, exact), query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact }; + const internal = evaluateOhProjectionV1(input); + const external = evaluateOhProjectionWithSussV1(input); + expect(external.engine).toBe(OH_PROJECTION_SUSS_ENGINE_V1); + expect(external.identity).toEqual(internal.identity); + expect(external.rows).toEqual(internal.rows); + expect(external.stats).toEqual(internal.stats); + }); + + test("rejects programs Suss cannot prove within the requested bound", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + expect(() => evaluateOhProjectionWithSussV1({ dataset: dataset(records, exact), + options: { maximumDerivedTuples: 1 }, query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact })).toThrow("cannot prove"); + }); +}); diff --git a/src/projection.ts b/src/projection.ts new file mode 100644 index 0000000..968dbef --- /dev/null +++ b/src/projection.ts @@ -0,0 +1,1090 @@ +import { + canonicalJson, + canonicalSha256, + hasExactKeys, + isPlainRecord, + orderedUnique, + parseSha256Hex, + safeCode, + sortUnique, + utf8ByteLength, + type JsonPrimitive, + type Sha256Hex, +} from "./canonical"; +import { OH_CONTRACT_MANIFEST_V1 } from "./contract"; +import { + knowledgeGraphRecordRefV1, + OH_GRAPH_LIMITS_V1, + OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + parseKnowledgeGraphRecordV1, + type KnowledgeGraphRecordKindV1, + type KnowledgeGraphRecordRefV1, + type KnowledgeGraphRecordV1, +} from "./graph"; + +export const OH_PROJECTION_FORMAT_VERSION_V1 = 1 as const; +export const OH_PROJECTION_SEMANTICS_V1 = "oh.projection.positive-datalog.v1" as const; +export const OH_PROJECTION_INTERNAL_ENGINE_V1 = "oh.naive.positive.v1" as const; + +export const OH_PROJECTION_LIMITS_V1 = Object.freeze({ + arity: 32, + atomBytes: 16 * 1024, + derivedTuples: 262_144, + facts: 262_144, + literalsPerRule: 64, + proofDepth: 128, + proofNodes: 4_096, + queryLiterals: 64, + queryMatches: 262_144, + queryResults: 65_536, + relations: 4_096, + rounds: 1_024, + rules: 1_024, + sourcesPerFact: 64, + variables: 256, +}); + +export type OhProjectionAtomV1 = JsonPrimitive; + +export type OhProjectionSnapshotV1 = Readonly<{ + contractSha256: Sha256Hex; + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordRefs: readonly KnowledgeGraphRecordRefV1[]; + recordsSha256: Sha256Hex; + sequence: number; + snapshotSha256: Sha256Hex; + spaceId: string; + v: 1; +}>; + +export type OhProjectionFactSourceV1 = Readonly<{ + key: string; + recordSha256: Sha256Hex; + v: 1; +}>; + +export type OhProjectionFactV1 = Readonly<{ + factSha256: Sha256Hex; + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}>; + +export type OhProjectionDatasetV1 = Readonly<{ + datasetSha256: Sha256Hex; + extractorSha256: Sha256Hex; + factPackId: string; + factPackRevision: number; + factPackSha256: Sha256Hex; + facts: readonly OhProjectionFactV1[]; + factsSha256: Sha256Hex; + snapshotSha256: Sha256Hex; + v: 1; +}>; + +const recordFactExtractorPayloadV1 = { + factPackId: "oh.record-facts", + factPackRevision: 1, + relations: ["oh.dependency", "oh.record"], + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1, +} as const; + +export const OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1 = Object.freeze({ + ...recordFactExtractorPayloadV1, + extractorSha256: canonicalSha256(recordFactExtractorPayloadV1), +}); + +export type OhProjectionTermV1 = + | Readonly<{ kind: "constant"; v: 1; value: OhProjectionAtomV1 }> + | Readonly<{ kind: "variable"; name: string; v: 1 }>; + +export type OhProjectionLiteralV1 = Readonly<{ + relation: string; + terms: readonly OhProjectionTermV1[]; + v: 1; +}>; + +export type OhProjectionRuleV1 = Readonly<{ + body: readonly OhProjectionLiteralV1[]; + head: OhProjectionLiteralV1; + ruleId: string; + ruleSha256: Sha256Hex; + v: 1; +}>; + +export type OhProjectionRulePackV1 = Readonly<{ + rulePackId: string; + rulePackRevision: number; + rulePackSha256: Sha256Hex; + rules: readonly OhProjectionRuleV1[]; + rulesSha256: Sha256Hex; + semantics: typeof OH_PROJECTION_SEMANTICS_V1; + v: 1; +}>; + +export type OhProjectionQueryV1 = Readonly<{ + find: readonly string[]; + limit: number; + queryId: string; + querySha256: Sha256Hex; + where: readonly OhProjectionLiteralV1[]; + v: 1; +}>; + +export type OhProjectionIdentityV1 = Readonly<{ + contractSha256: Sha256Hex; + datasetSha256: Sha256Hex; + projectionSha256: Sha256Hex; + querySha256: Sha256Hex; + rulePackSha256: Sha256Hex; + semantics: typeof OH_PROJECTION_SEMANTICS_V1; + snapshotSha256: Sha256Hex; + v: 1; +}>; + +export type OhProjectionProofV1 = + | Readonly<{ + kind: "fact"; + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }> + | Readonly<{ + kind: "derived"; + premises: readonly OhProjectionProofV1[]; + relation: string; + ruleId: string; + ruleSha256: Sha256Hex; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }> + | Readonly<{ + kind: "truncated"; + reason: "cycle" | "depth" | "nodes"; + relation: string; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }>; + +export type OhProjectionResultRowV1 = Readonly<{ + proofs: readonly OhProjectionProofV1[]; + values: readonly OhProjectionAtomV1[]; + v: 1; +}>; + +export type OhProjectionResultV1 = Readonly<{ + authority: "derived"; + cache: Readonly<{ strategy: "full-rebuild"; v: 1 }>; + engine: string; + evaluation: Readonly<{ + maximumDerivedTuples: number; + maximumProofDepth: number; + maximumProofNodes: number; + maximumRounds: number; + v: 1; + }>; + identity: OhProjectionIdentityV1; + resultSha256: Sha256Hex; + rows: readonly OhProjectionResultRowV1[]; + stats: Readonly<{ + baseFacts: number; + derivedFacts: number; + queryMatches: number; + relations: number; + rounds: number; + truncated: boolean; + v: 1; + }>; + v: 1; +}>; + +export type OhProjectionEvaluationOptionsV1 = Readonly<{ + maximumDerivedTuples?: number; + maximumProofDepth?: number; + maximumProofNodes?: number; + maximumRounds?: number; +}>; + +export type OhProjectionInvalidationReasonV1 = + | "dataset-changed" + | "query-changed" + | "rule-pack-changed" + | "snapshot-changed"; + +export type OhProjectionInvalidationV1 = + | Readonly<{ kind: "reusable"; v: 1 }> + | Readonly<{ + kind: "full-rebuild"; + reasons: readonly OhProjectionInvalidationReasonV1[]; + v: 1; + }>; + +type ProjectionHeadInputV1 = Readonly<{ + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordsSha256: Sha256Hex; + sequence: number; +}>; + +function nonnegativeInteger(value: unknown): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null; +} + +function positiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null { + return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= maximum + ? value as number : null; +} + +function projectionName(value: unknown, maximumLength = 128): string | null { + return safeCode(value, maximumLength); +} + +function compareCanonical(left: unknown, right: unknown): number { + const leftKey = canonicalJson(left); + const rightKey = canonicalJson(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; +} + +function compareProjectionFacts(left: OhProjectionFactV1, right: OhProjectionFactV1): number { + return compareCanonical([left.relation, left.tuple], [right.relation, right.tuple]); +} + +const INVALID_PROJECTION_ATOM = Symbol("invalid-projection-atom"); + +function atom(value: unknown): OhProjectionAtomV1 | typeof INVALID_PROJECTION_ATOM { + if (value !== null && typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string") { + return INVALID_PROJECTION_ATOM; + } + try { + const encoded = canonicalJson(value); + return utf8ByteLength(encoded) <= OH_PROJECTION_LIMITS_V1.atomBytes + ? value as OhProjectionAtomV1 : INVALID_PROJECTION_ATOM; + } catch { + return INVALID_PROJECTION_ATOM; + } +} + +function tuple(value: unknown): readonly OhProjectionAtomV1[] | null { + if (!Array.isArray(value) || value.length < 1 || value.length > OH_PROJECTION_LIMITS_V1.arity) return null; + const parsed = value.map(atom); + return parsed.some((item) => item === INVALID_PROJECTION_ATOM) + ? null : parsed as readonly OhProjectionAtomV1[]; +} + +function parseRecordRef(value: unknown): KnowledgeGraphRecordRefV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["dependencies", "key", "kind", "sha256", "v"]) + || value.v !== 1 || !Array.isArray(value.dependencies)) return null; + const key = safeCode(value.key, 512); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === value.kind); + const sha256 = parseSha256Hex(value.sha256); + const dependencies = value.dependencies.map((dependency) => safeCode(dependency, 512)); + if (key === null || kind === undefined || sha256 === null + || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord + || dependencies.some((dependency) => dependency === null) + || !orderedUnique(dependencies as readonly string[], String) + || dependencies.includes(key)) return null; + return { dependencies: dependencies as readonly string[], key, kind, sha256, v: 1 }; +} + +export function createOhProjectionSnapshotV1(input: Readonly<{ + head: ProjectionHeadInputV1; + records: readonly KnowledgeGraphRecordV1[]; + spaceId: string; +}>): OhProjectionSnapshotV1 { + const spaceId = projectionName(input.spaceId); + const generation = nonnegativeInteger(input.head.generation); + const sequence = nonnegativeInteger(input.head.sequence); + const operationSha256 = input.head.operationSha256 === null ? null : parseSha256Hex(input.head.operationSha256); + const graphRevisionSha256 = input.head.graphRevisionSha256 === null + ? null : parseSha256Hex(input.head.graphRevisionSha256); + const declaredRecordsSha256 = parseSha256Hex(input.head.recordsSha256); + if (spaceId === null || generation === null || sequence === null || generation !== sequence + || (input.head.operationSha256 !== null && operationSha256 === null) + || (input.head.graphRevisionSha256 !== null && graphRevisionSha256 === null) + || ((sequence === 0) !== (operationSha256 === null)) + || ((sequence === 0) !== (graphRevisionSha256 === null)) + || declaredRecordsSha256 === null || input.records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid projection snapshot head."); + } + const records = input.records.map(parseKnowledgeGraphRecordV1); + if (records.some((record) => record === null)) throw new TypeError("Invalid record in projection snapshot."); + const recordRefs = sortUnique( + (records as readonly KnowledgeGraphRecordV1[]).map(knowledgeGraphRecordRefV1), + (reference) => reference.key, + ); + const recordsSha256 = canonicalSha256(recordRefs); + if (recordsSha256 !== declaredRecordsSha256) { + throw new TypeError("Projection snapshot records do not reproduce the declared head."); + } + const keys = new Set(recordRefs.map((reference) => reference.key)); + if (recordRefs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) { + throw new TypeError("Projection snapshot has a missing record dependency."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs, + recordsSha256, + sequence, + spaceId, + v: 1 as const, + }; + return { ...payload, snapshotSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionSnapshotV1(value: unknown): OhProjectionSnapshotV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["contractSha256", "generation", + "graphRevisionSha256", "operationSha256", "recordRefs", "recordsSha256", "sequence", + "snapshotSha256", "spaceId", "v"]) || value.v !== 1 || !Array.isArray(value.recordRefs) + || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const generation = nonnegativeInteger(value.generation); + const sequence = nonnegativeInteger(value.sequence); + const graphRevisionSha256 = value.graphRevisionSha256 === null + ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + const spaceId = projectionName(value.spaceId); + const recordRefs = value.recordRefs.map(parseRecordRef); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || generation === null || sequence === null + || generation !== sequence || spaceId === null || recordsSha256 === null || snapshotSha256 === null + || (value.graphRevisionSha256 !== null && graphRevisionSha256 === null) + || (value.operationSha256 !== null && operationSha256 === null) + || ((sequence === 0) !== (operationSha256 === null)) + || ((sequence === 0) !== (graphRevisionSha256 === null)) + || recordRefs.some((reference) => reference === null)) return null; + const refs = recordRefs as readonly KnowledgeGraphRecordRefV1[]; + if (!orderedUnique(refs, (reference) => reference.key) || canonicalSha256(refs) !== recordsSha256) return null; + const keys = new Set(refs.map((reference) => reference.key)); + if (refs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) return null; + const payload = { contractSha256, generation, graphRevisionSha256, operationSha256, + recordRefs: refs, recordsSha256, sequence, spaceId, v: 1 as const }; + return canonicalSha256(payload) === snapshotSha256 ? { ...payload, snapshotSha256 } : null; +} + +export function createOhProjectionFactV1(input: Readonly<{ + relation: string; + sources: readonly OhProjectionFactSourceV1[]; + tuple: readonly OhProjectionAtomV1[]; +}>): OhProjectionFactV1 { + const relation = projectionName(input.relation); + const parsedTuple = tuple(input.tuple); + if (relation === null || parsedTuple === null || input.sources.length < 1 + || input.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) { + throw new TypeError("Invalid projection fact."); + } + const sources = input.sources.map((source) => { + if (!isPlainRecord(source) || !hasExactKeys(source, ["key", "recordSha256", "v"]) || source.v !== 1) { + throw new TypeError("Invalid projection fact source."); + } + const key = safeCode(source.key, 512); + const recordSha256 = parseSha256Hex(source.recordSha256); + if (key === null || recordSha256 === null) throw new TypeError("Invalid projection fact source."); + return { key, recordSha256, v: 1 as const }; + }).sort(compareCanonical); + if (!orderedUnique(sources, (source) => source.key)) { + throw new TypeError("Projection fact sources must have unique record keys."); + } + const payload = { relation, sources, tuple: parsedTuple, v: 1 as const }; + return { ...payload, factSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionFactV1(value: unknown): OhProjectionFactV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["factSha256", "relation", "sources", "tuple", "v"]) + || value.v !== 1 || !Array.isArray(value.sources) || !Array.isArray(value.tuple)) return null; + const factSha256 = parseSha256Hex(value.factSha256); + try { + const fact = createOhProjectionFactV1({ relation: value.relation as string, + sources: value.sources as OhProjectionFactSourceV1[], tuple: value.tuple as OhProjectionAtomV1[] }); + return factSha256 !== null && fact.factSha256 === factSha256 ? fact : null; + } catch { + return null; + } +} + +function mergeProjectionFacts(facts: readonly OhProjectionFactV1[]): readonly OhProjectionFactV1[] { + const grouped = new Map; + tuple: readonly OhProjectionAtomV1[] }>(); + for (const fact of facts) { + const identity = canonicalJson([fact.relation, fact.tuple]); + let group = grouped.get(identity); + if (group === undefined) { + group = { relation: fact.relation, sources: new Map(), tuple: fact.tuple }; + grouped.set(identity, group); + } + for (const source of fact.sources) { + const existing = group.sources.get(source.key); + if (existing !== undefined && existing.recordSha256 !== source.recordSha256) { + throw new TypeError("One fact source key is bound to multiple record digests."); + } + group.sources.set(source.key, source); + } + } + return [...grouped.values()].map((group) => createOhProjectionFactV1({ relation: group.relation, + sources: [...group.sources.values()], tuple: group.tuple })).sort(compareProjectionFacts); +} + +export function createOhProjectionDatasetV1(input: Readonly<{ + extractorSha256: Sha256Hex; + factPackId: string; + factPackRevision: number; + facts: readonly OhProjectionFactV1[]; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionDatasetV1 { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const extractorSha256 = parseSha256Hex(input.extractorSha256); + const factPackId = projectionName(input.factPackId); + const factPackRevision = positiveInteger(input.factPackRevision); + if (snapshot === null || extractorSha256 === null || factPackId === null || factPackRevision === null + || input.facts.length > OH_PROJECTION_LIMITS_V1.facts) throw new TypeError("Invalid projection dataset."); + const parsedFacts = input.facts.map(parseOhProjectionFactV1); + if (parsedFacts.some((fact) => fact === null)) throw new TypeError("Invalid fact in projection dataset."); + const facts = mergeProjectionFacts(parsedFacts as readonly OhProjectionFactV1[]); + if (facts.length > OH_PROJECTION_LIMITS_V1.facts) throw new RangeError("Projection dataset has too many facts."); + const refs = new Map(snapshot.recordRefs.map((reference) => [reference.key, reference.sha256])); + for (const fact of facts) { + for (const source of fact.sources) { + if (refs.get(source.key) !== source.recordSha256) { + throw new TypeError("Projection fact source is not present at the exact input snapshot."); + } + } + } + const factPackPayload = { extractorSha256, factPackId, factPackRevision, + semantics: OH_PROJECTION_SEMANTICS_V1, v: 1 as const }; + const factPackSha256 = canonicalSha256(factPackPayload); + const factsSha256 = canonicalSha256(facts); + const payload = { extractorSha256, factPackId, factPackRevision, factPackSha256, facts, + factsSha256, snapshotSha256: snapshot.snapshotSha256, v: 1 as const }; + return { ...payload, datasetSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionDatasetV1(value: unknown, + snapshot: OhProjectionSnapshotV1): OhProjectionDatasetV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["datasetSha256", "extractorSha256", "factPackId", + "factPackRevision", "factPackSha256", "facts", "factsSha256", "snapshotSha256", "v"]) + || value.v !== 1 || !Array.isArray(value.facts)) return null; + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const declaredFactPackSha256 = parseSha256Hex(value.factPackSha256); + const declaredFactsSha256 = parseSha256Hex(value.factsSha256); + try { + const dataset = createOhProjectionDatasetV1({ extractorSha256: value.extractorSha256 as Sha256Hex, + factPackId: value.factPackId as string, factPackRevision: value.factPackRevision as number, + facts: value.facts as OhProjectionFactV1[], snapshot }); + return datasetSha256 !== null && declaredFactPackSha256 === dataset.factPackSha256 + && declaredFactsSha256 === dataset.factsSha256 && value.snapshotSha256 === dataset.snapshotSha256 + && dataset.datasetSha256 === datasetSha256 + ? dataset : null; + } catch { + return null; + } +} + +export function ohProjectionVariableV1(name: string): OhProjectionTermV1 { + const parsed = projectionName(name); + if (parsed === null) throw new TypeError("Invalid projection variable name."); + return { kind: "variable", name: parsed, v: 1 }; +} + +export function ohProjectionConstantV1(value: OhProjectionAtomV1): OhProjectionTermV1 { + const parsed = atom(value); + if (parsed === INVALID_PROJECTION_ATOM) throw new TypeError("Invalid projection constant."); + return { kind: "constant", v: 1, value: parsed }; +} + +export function createOhProjectionLiteralV1(input: Readonly<{ + relation: string; + terms: readonly OhProjectionTermV1[]; +}>): OhProjectionLiteralV1 { + const relation = projectionName(input.relation); + if (relation === null || input.terms.length < 1 || input.terms.length > OH_PROJECTION_LIMITS_V1.arity) { + throw new TypeError("Invalid projection literal."); + } + const terms = input.terms.map((term) => parseOhProjectionTermV1(term)); + if (terms.some((term) => term === null)) throw new TypeError("Invalid term in projection literal."); + return { relation, terms: terms as readonly OhProjectionTermV1[], v: 1 }; +} + +export function parseOhProjectionTermV1(value: unknown): OhProjectionTermV1 | null { + if (!isPlainRecord(value) || value.v !== 1) return null; + if (value.kind === "variable" && hasExactKeys(value, ["kind", "name", "v"])) { + const name = projectionName(value.name); + return name === null ? null : { kind: "variable", name, v: 1 }; + } + if (value.kind === "constant" && hasExactKeys(value, ["kind", "v", "value"])) { + const parsed = atom(value.value); + return parsed === INVALID_PROJECTION_ATOM ? null : { kind: "constant", v: 1, value: parsed }; + } + return null; +} + +export function parseOhProjectionLiteralV1(value: unknown): OhProjectionLiteralV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["relation", "terms", "v"]) + || value.v !== 1 || !Array.isArray(value.terms)) return null; + try { + return createOhProjectionLiteralV1({ relation: value.relation as string, + terms: value.terms as OhProjectionTermV1[] }); + } catch { + return null; + } +} + +function literalVariables(literal: OhProjectionLiteralV1): readonly string[] { + return literal.terms.flatMap((term) => term.kind === "variable" ? [term.name] : []); +} + +export function createOhProjectionRuleV1(input: Readonly<{ + body: readonly OhProjectionLiteralV1[]; + head: OhProjectionLiteralV1; + ruleId: string; +}>): OhProjectionRuleV1 { + const ruleId = projectionName(input.ruleId); + const head = parseOhProjectionLiteralV1(input.head); + if (ruleId === null || head === null || input.body.length < 1 + || input.body.length > OH_PROJECTION_LIMITS_V1.literalsPerRule) throw new TypeError("Invalid projection rule."); + const body = input.body.map(parseOhProjectionLiteralV1); + if (body.some((literal) => literal === null)) throw new TypeError("Invalid body literal in projection rule."); + const bound = new Set((body as readonly OhProjectionLiteralV1[]).flatMap(literalVariables)); + if (literalVariables(head).some((variable) => !bound.has(variable)) + || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projection rule head variable must be bound in its body."); + } + const payload = { body: body as readonly OhProjectionLiteralV1[], head, ruleId, v: 1 as const }; + return { ...payload, ruleSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionRuleV1(value: unknown): OhProjectionRuleV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["body", "head", "ruleId", "ruleSha256", "v"]) + || value.v !== 1 || !Array.isArray(value.body)) return null; + const ruleSha256 = parseSha256Hex(value.ruleSha256); + try { + const rule = createOhProjectionRuleV1({ body: value.body as OhProjectionLiteralV1[], + head: value.head as OhProjectionLiteralV1, ruleId: value.ruleId as string }); + return ruleSha256 !== null && rule.ruleSha256 === ruleSha256 ? rule : null; + } catch { + return null; + } +} + +export function createOhProjectionRulePackV1(input: Readonly<{ + rulePackId: string; + rulePackRevision: number; + rules: readonly OhProjectionRuleV1[]; +}>): OhProjectionRulePackV1 { + const rulePackId = projectionName(input.rulePackId); + const rulePackRevision = positiveInteger(input.rulePackRevision); + if (rulePackId === null || rulePackRevision === null || input.rules.length < 1 + || input.rules.length > OH_PROJECTION_LIMITS_V1.rules) throw new TypeError("Invalid projection rule pack."); + const parsedRules = input.rules.map(parseOhProjectionRuleV1); + if (parsedRules.some((rule) => rule === null)) throw new TypeError("Invalid rule in projection rule pack."); + const rules = sortUnique(parsedRules as readonly OhProjectionRuleV1[], (rule) => rule.ruleId); + const rulesSha256 = canonicalSha256(rules); + const payload = { rulePackId, rulePackRevision, rules, rulesSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, v: 1 as const }; + return { ...payload, rulePackSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionRulePackV1(value: unknown): OhProjectionRulePackV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["rulePackId", "rulePackRevision", + "rulePackSha256", "rules", "rulesSha256", "semantics", "v"]) || value.v !== 1 + || value.semantics !== OH_PROJECTION_SEMANTICS_V1 || !Array.isArray(value.rules)) return null; + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const rulesSha256 = parseSha256Hex(value.rulesSha256); + try { + const pack = createOhProjectionRulePackV1({ rulePackId: value.rulePackId as string, + rulePackRevision: value.rulePackRevision as number, rules: value.rules as OhProjectionRuleV1[] }); + return rulePackSha256 === pack.rulePackSha256 && rulesSha256 === pack.rulesSha256 ? pack : null; + } catch { + return null; + } +} + +export function createOhProjectionQueryV1(input: Readonly<{ + find: readonly string[]; + limit?: number; + queryId: string; + where: readonly OhProjectionLiteralV1[]; +}>): OhProjectionQueryV1 { + const queryId = projectionName(input.queryId); + const limit = positiveInteger(input.limit ?? 1_000, OH_PROJECTION_LIMITS_V1.queryResults); + if (queryId === null || limit === null || input.find.length < 1 + || input.find.length > OH_PROJECTION_LIMITS_V1.arity || input.where.length < 1 + || input.where.length > OH_PROJECTION_LIMITS_V1.queryLiterals) throw new TypeError("Invalid projection query."); + const find = input.find.map((name) => projectionName(name)); + const where = input.where.map(parseOhProjectionLiteralV1); + if (find.some((name) => name === null) || !orderedUnique([...find as string[]].sort(), String) + || where.some((literal) => literal === null)) throw new TypeError("Invalid projection query variables or literals."); + const bound = new Set((where as readonly OhProjectionLiteralV1[]).flatMap(literalVariables)); + if ((find as readonly string[]).some((name) => !bound.has(name)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projected query variable must be bound in the query body."); + } + const payload = { find: find as readonly string[], limit, queryId, + where: where as readonly OhProjectionLiteralV1[], v: 1 as const }; + return { ...payload, querySha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionQueryV1(value: unknown): OhProjectionQueryV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["find", "limit", "queryId", "querySha256", "where", "v"]) + || value.v !== 1 || !Array.isArray(value.find) || !Array.isArray(value.where)) return null; + const querySha256 = parseSha256Hex(value.querySha256); + try { + const query = createOhProjectionQueryV1({ find: value.find as string[], limit: value.limit as number, + queryId: value.queryId as string, where: value.where as OhProjectionLiteralV1[] }); + return querySha256 !== null && query.querySha256 === querySha256 ? query : null; + } catch { + return null; + } +} + +export function createOhProjectionIdentityV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionIdentityV1 { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const query = parseOhProjectionQueryV1(input.query); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + if (snapshot === null || dataset === null || query === null || rulePack === null) { + throw new TypeError("Invalid projection identity input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + datasetSha256: dataset.datasetSha256, + querySha256: query.querySha256, + rulePackSha256: rulePack.rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256: snapshot.snapshotSha256, + v: 1 as const, + }; + return { ...payload, projectionSha256: canonicalSha256(payload) }; +} + +export function parseOhProjectionIdentityV1(value: unknown): OhProjectionIdentityV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["contractSha256", "datasetSha256", + "projectionSha256", "querySha256", "rulePackSha256", "semantics", "snapshotSha256", "v"]) + || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1) return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const projectionSha256 = parseSha256Hex(value.projectionSha256); + const querySha256 = parseSha256Hex(value.querySha256); + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null + || projectionSha256 === null || querySha256 === null || rulePackSha256 === null + || snapshotSha256 === null) return null; + const payload = { contractSha256, datasetSha256, querySha256, rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, snapshotSha256, v: 1 as const }; + return canonicalSha256(payload) === projectionSha256 ? { ...payload, projectionSha256 } : null; +} + +export function invalidationForOhProjectionV1(previous: OhProjectionIdentityV1, + next: OhProjectionIdentityV1): OhProjectionInvalidationV1 { + const parsedPrevious = parseOhProjectionIdentityV1(previous); + const parsedNext = parseOhProjectionIdentityV1(next); + if (parsedPrevious === null || parsedNext === null) throw new TypeError("Invalid projection identity."); + if (parsedPrevious.projectionSha256 === parsedNext.projectionSha256) return { kind: "reusable", v: 1 }; + const reasons: OhProjectionInvalidationReasonV1[] = []; + if (parsedPrevious.snapshotSha256 !== parsedNext.snapshotSha256) reasons.push("snapshot-changed"); + if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) reasons.push("dataset-changed"); + if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) reasons.push("rule-pack-changed"); + if (parsedPrevious.querySha256 !== parsedNext.querySha256) reasons.push("query-changed"); + return { kind: "full-rebuild", reasons, v: 1 }; +} + +type TupleReference = Readonly<{ relation: string; tuple: readonly OhProjectionAtomV1[] }>; +type FactWitness = Readonly<{ kind: "fact"; sources: readonly OhProjectionFactSourceV1[] }>; +type DerivedWitness = Readonly<{ + kind: "derived"; + premises: readonly TupleReference[]; + rule: OhProjectionRuleV1; +}>; +type TupleState = Readonly<{ + tuple: readonly OhProjectionAtomV1[]; + witness: FactWitness | DerivedWitness; +}>; +type RelationState = Map; +type MaterializedProjection = Readonly<{ + baseFacts: number; + derivedFacts: number; + relations: Map; + rounds: number; +}>; + +function tupleKey(value: readonly OhProjectionAtomV1[]): string { + return canonicalJson(value); +} + +function referenceKey(reference: TupleReference): string { + return canonicalJson([reference.relation, reference.tuple]); +} + +function relationTuples(relations: Map, relation: string): readonly TupleState[] { + return [...(relations.get(relation)?.values() ?? [])] + .sort((left, right) => compareCanonical(left.tuple, right.tuple)); +} + +function setArity(arities: Map, relation: string, arity: number): void { + const existing = arities.get(relation); + if (existing !== undefined && existing !== arity) { + throw new TypeError(`Projection relation ${relation} is used with conflicting arities.`); + } + arities.set(relation, arity); + if (arities.size > OH_PROJECTION_LIMITS_V1.relations) throw new RangeError("Projection uses too many relations."); +} + +function validateProgramArities(dataset: OhProjectionDatasetV1, rulePack: OhProjectionRulePackV1, + query: OhProjectionQueryV1): void { + const arities = new Map(); + for (const fact of dataset.facts) setArity(arities, fact.relation, fact.tuple.length); + for (const rule of rulePack.rules) { + setArity(arities, rule.head.relation, rule.head.terms.length); + for (const literal of rule.body) setArity(arities, literal.relation, literal.terms.length); + } + for (const literal of query.where) setArity(arities, literal.relation, literal.terms.length); +} + +type Binding = Map; + +function sameAtom(left: OhProjectionAtomV1, right: OhProjectionAtomV1): boolean { + return left === right; +} + +function unifyLiteral(literal: OhProjectionLiteralV1, state: TupleState, binding: Binding): Binding | null { + const next = new Map(binding); + for (let index = 0; index < literal.terms.length; index += 1) { + const term = literal.terms[index] as OhProjectionTermV1; + const value = state.tuple[index] as OhProjectionAtomV1; + if (term.kind === "constant") { + if (!sameAtom(term.value, value)) return null; + continue; + } + if (next.has(term.name)) { + if (!sameAtom(next.get(term.name) as OhProjectionAtomV1, value)) return null; + } else next.set(term.name, value); + } + return next; +} + +type BodyMatch = Readonly<{ binding: Binding; premises: readonly TupleReference[] }>; + +function matchBody(relations: Map, body: readonly OhProjectionLiteralV1[], + maximumMatches: number): readonly BodyMatch[] { + let matches: readonly BodyMatch[] = [{ binding: new Map(), premises: [] }]; + for (const literal of body) { + const next: BodyMatch[] = []; + const candidates = relationTuples(relations, literal.relation); + for (const match of matches) { + for (const candidate of candidates) { + const binding = unifyLiteral(literal, candidate, match.binding); + if (binding === null) continue; + next.push({ binding, premises: [...match.premises, { relation: literal.relation, + tuple: candidate.tuple }] }); + if (next.length > maximumMatches) throw new RangeError("Projection join exceeds its match bound."); + } + } + matches = next; + if (matches.length === 0) break; + } + return matches; +} + +function instantiateHead(head: OhProjectionLiteralV1, binding: Binding): readonly OhProjectionAtomV1[] { + return head.terms.map((term) => term.kind === "constant" + ? term.value : binding.get(term.name) as OhProjectionAtomV1); +} + +function canonicalWitness(witness: FactWitness | DerivedWitness): string { + if (witness.kind === "fact") return canonicalJson(witness); + return canonicalJson({ kind: witness.kind, premises: witness.premises, ruleSha256: witness.rule.ruleSha256 }); +} + +function materializeNaive(input: Readonly<{ + dataset: OhProjectionDatasetV1; + maximumDerivedTuples: number; + maximumRounds: number; + rulePack: OhProjectionRulePackV1; +}>): MaterializedProjection { + const relations = new Map(); + for (const fact of input.dataset.facts) { + let relation = relations.get(fact.relation); + if (relation === undefined) { + relation = new Map(); + relations.set(fact.relation, relation); + } + relation.set(tupleKey(fact.tuple), { tuple: fact.tuple, witness: { kind: "fact", sources: fact.sources } }); + } + let derivedFacts = 0; + let rounds = 0; + while (true) { + const candidates = new Map>(); + for (const rule of input.rulePack.rules) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + const derivedTuple = instantiateHead(rule.head, match.binding); + const relation = relations.get(rule.head.relation); + const key = tupleKey(derivedTuple); + if (relation?.has(key) === true) continue; + const state: TupleState = { tuple: derivedTuple, + witness: { kind: "derived", premises: match.premises, rule } }; + const identity = referenceKey({ relation: rule.head.relation, tuple: derivedTuple }); + const existing = candidates.get(identity); + if (existing === undefined || canonicalWitness(state.witness) < canonicalWitness(existing.state.witness)) { + candidates.set(identity, { relation: rule.head.relation, state }); + } + } + } + if (candidates.size === 0) break; + if (rounds >= input.maximumRounds) throw new RangeError("Projection exceeds its evaluation round bound."); + if (derivedFacts + candidates.size > input.maximumDerivedTuples) { + throw new RangeError("Projection exceeds its derived tuple bound."); + } + const ordered = [...candidates.values()].sort((left, right) => compareCanonical( + [left.relation, left.state.tuple], [right.relation, right.state.tuple])); + for (const candidate of ordered) { + let relation = relations.get(candidate.relation); + if (relation === undefined) { + relation = new Map(); + relations.set(candidate.relation, relation); + } + relation.set(tupleKey(candidate.state.tuple), candidate.state); + } + derivedFacts += candidates.size; + rounds += 1; + } + return { baseFacts: input.dataset.facts.length, derivedFacts, relations, rounds }; +} + +function boundedOption(value: number | undefined, fallback: number, maximum: number, label: string): number { + const parsed = positiveInteger(value ?? fallback, maximum); + if (parsed === null) throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`); + return parsed; +} + +type ResolvedEvaluationOptions = Readonly<{ + maximumDerivedTuples: number; + maximumProofDepth: number; + maximumProofNodes: number; + maximumRounds: number; +}>; + +function resolveEvaluationOptions(options: OhProjectionEvaluationOptionsV1): ResolvedEvaluationOptions { + return { + maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, + OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), + maximumProofDepth: boundedOption(options.maximumProofDepth, 32, + OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), + maximumProofNodes: boundedOption(options.maximumProofNodes, 1_024, + OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, + OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds"), + }; +} + +function proofForReference(relations: Map, reference: TupleReference, + budget: { nodes: number }, options: ResolvedEvaluationOptions, depth: number, + visiting: Set): OhProjectionProofV1 | null { + if (budget.nodes >= options.maximumProofNodes) return null; + if (budget.nodes === options.maximumProofNodes - 1) { + budget.nodes += 1; + return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + budget.nodes += 1; + if (depth >= options.maximumProofDepth) { + return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const identity = referenceKey(reference); + if (visiting.has(identity)) { + return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + } + const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); + if (state === undefined) throw new Error("Projection proof references a tuple outside the materialized result."); + if (state.witness.kind === "fact") { + return { kind: "fact", relation: reference.relation, sources: state.witness.sources, + tuple: reference.tuple, v: 1 }; + } + visiting.add(identity); + try { + const premises: OhProjectionProofV1[] = []; + for (const premise of state.witness.premises) { + const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); + if (proof === null) break; + premises.push(proof); + } + return { kind: "derived", premises, + relation: reference.relation, ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, tuple: reference.tuple, v: 1 }; + } finally { + visiting.delete(identity); + } +} + +function buildProjectionResult(input: Readonly<{ + dataset: OhProjectionDatasetV1; + engine: string; + materialized: MaterializedProjection; + options: ResolvedEvaluationOptions; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1 { + const matches = matchBody(input.materialized.relations, input.query.where, + OH_PROJECTION_LIMITS_V1.queryMatches); + const byValues = new Map(); + for (const match of matches) { + const values = input.query.find.map((name) => match.binding.get(name) as OhProjectionAtomV1); + const key = tupleKey(values); + const existing = byValues.get(key); + if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) byValues.set(key, match); + } + const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + const truncated = ordered.length > input.query.limit; + const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const values = JSON.parse(key) as OhProjectionAtomV1[]; + const budget = { nodes: 0 }; + const proofs: OhProjectionProofV1[] = []; + for (const premise of match.premises) { + const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set()); + if (proof === null) break; + proofs.push(proof); + } + return { proofs, values, v: 1 as const }; + }); + const identity = createOhProjectionIdentityV1({ dataset: input.dataset, query: input.query, + rulePack: input.rulePack, snapshot: input.snapshot }); + const payload = { + authority: "derived" as const, + cache: { strategy: "full-rebuild" as const, v: 1 as const }, + engine: input.engine, + evaluation: { ...input.options, v: 1 as const }, + identity, + rows, + stats: { baseFacts: input.materialized.baseFacts, derivedFacts: input.materialized.derivedFacts, + queryMatches: matches.length, relations: input.materialized.relations.size, + rounds: input.materialized.rounds, truncated, v: 1 as const }, + v: 1 as const, + }; + return { ...payload, resultSha256: canonicalSha256(payload) }; +} + +export function evaluateOhProjectionV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1 { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + if (snapshot === null || dataset === null || rulePack === null || query === null) { + throw new TypeError("Invalid projection snapshot, dataset, rule pack, or query."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const materialized = materializeNaive({ dataset, + maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack }); + return buildProjectionResult({ dataset, engine: OH_PROJECTION_INTERNAL_ENGINE_V1, + materialized, options, query, rulePack, snapshot }); +} + +/** + * Internal adapter seam. It is exported for package-owned optional engines, + * not as authority: callers receive the same derived-only result envelope. + */ +export function evaluateOhProjectionWithMaterializerV1(input: Readonly<{ + dataset: OhProjectionDatasetV1; + engine: string; + materialize: (program: Readonly<{ + dataset: OhProjectionDatasetV1; + maximumDerivedTuples: number; + maximumRounds: number; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + }>) => Readonly<{ relationFacts: ReadonlyMap }>; + options?: OhProjectionEvaluationOptionsV1; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; + snapshot: OhProjectionSnapshotV1; +}>): OhProjectionResultV1 { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + const engine = safeCode(input.engine, 256); + if (snapshot === null || dataset === null || rulePack === null || query === null || engine === null) { + throw new TypeError("Invalid projection adapter input."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const external = input.materialize({ dataset, + maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, query, rulePack }); + const witnessMaterialization = materializeNaive({ dataset, + maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack }); + const externalCanonical = new Map(); + for (const [relationName, tuples] of external.relationFacts) { + const relation = projectionName(relationName); + if (relation === null || tuples.length > OH_PROJECTION_LIMITS_V1.facts + options.maximumDerivedTuples) { + throw new TypeError("Projection adapter returned an invalid relation."); + } + const parsed = tuples.map(tuple); + if (parsed.some((value) => value === null)) throw new TypeError("Projection adapter returned an invalid tuple."); + const keys: string[] = []; + for (const value of parsed) { + if (value === null) throw new TypeError("Projection adapter returned an invalid tuple."); + keys.push(tupleKey(value)); + } + externalCanonical.set(relation, [...new Set(keys)].sort()); + } + const expectedCanonical = new Map([...witnessMaterialization.relations.entries()].map(([relation, states]) => + [relation, [...states.values()].map((state) => tupleKey(state.tuple)).sort()] as const)); + const relationNames = [...new Set([...externalCanonical.keys(), ...expectedCanonical.keys()])].sort(); + for (const relation of relationNames) { + if (canonicalJson(externalCanonical.get(relation) ?? []) !== canonicalJson(expectedCanonical.get(relation) ?? [])) { + throw new Error(`Projection adapter disagrees with Oh semantics for relation ${relation}.`); + } + } + return buildProjectionResult({ dataset, engine, + materialized: witnessMaterialization, options, query, rulePack, snapshot }); +} + +export type OhProjectionRecordFactOptionsV1 = Readonly<{ + includeDependencies?: boolean; + includeRecords?: boolean; +}>; + +/** Builds the stable structural fact layer shared by every domain fact pack. */ +export function createOhProjectionRecordFactsV1(records: readonly KnowledgeGraphRecordV1[], + options: OhProjectionRecordFactOptionsV1 = {}): readonly OhProjectionFactV1[] { + if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) throw new RangeError("Too many records for projection facts."); + const facts: OhProjectionFactV1[] = []; + for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { + const record = parseKnowledgeGraphRecordV1(candidate); + if (record === null) throw new TypeError("Invalid graph record for projection facts."); + const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 as const }]; + if (options.includeRecords !== false) { + facts.push(createOhProjectionFactV1({ relation: "oh.record", sources: source, + tuple: [record.key, record.kind, record.recordSha256] })); + } + if (options.includeDependencies !== false) { + for (const dependency of record.dependencies) { + facts.push(createOhProjectionFactV1({ relation: "oh.dependency", sources: source, + tuple: [record.key, dependency] })); + } + } + } + return facts.sort(compareProjectionFacts); +} + +export function isOhProjectionRecordKindV1(value: unknown): value is KnowledgeGraphRecordKindV1 { + return OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === value); +} diff --git a/tests/public-surface.test.ts b/tests/public-surface.test.ts index d8309ec..38157fe 100644 --- a/tests/public-surface.test.ts +++ b/tests/public-surface.test.ts @@ -29,6 +29,7 @@ const markdownFiles = [ "spec/v1/storage.md", "spec/v1/sync.md", "spec/v1/embedding.md", + "spec/v1/projection.md", "spec/v1/migration.md", "skills/oh/SKILL.md", ] as const; @@ -257,7 +258,7 @@ describe("versioned public contract", () => { const claims = new Set(); for (const path of markdownFiles) { const markdown = await readFile(join(root, path), "utf8"); - for (const match of markdown.matchAll(/@hraness\/oh(?:\/[a-z0-9-]+)?/gu)) claims.add(match[0]); + for (const match of markdown.matchAll(/@hraness\/oh(?:\/[a-z0-9-]+)*/gu)) claims.add(match[0]); } expect(claims.size).toBeGreaterThan(1); for (const claim of claims) { @@ -276,7 +277,9 @@ describe("versioned public contract", () => { const exports = packageJson.exports as Record; expect(Object.keys(exports).sort()).toEqual([ ".", + "./experimental/projection-suss", "./package.json", + "./projection", "./sdk", "./semantic", "./sqlite", @@ -299,7 +302,8 @@ describe("versioned public contract", () => { const peers = packageJson.peerDependencies as Record; const peerMetadata = packageJson.peerDependenciesMeta as Record>; - expect(peers).toEqual({ "@libsql/client": ">=0.17.4 <1", "@tobilu/qmd": "2.5.3" }); + expect(peers).toEqual({ "@libsql/client": ">=0.17.4 <1", "@suss/datalog": "0.20.0", + "@tobilu/qmd": "2.5.3" }); expect(Object.keys(peers).every((name) => peerMetadata[name]?.optional === true)).toBe(true); }); From de693b298e2dc41c6771cbdafde366670a2f4966 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 29 Aug 2026 18:07:01 -0400 Subject: [PATCH 2/3] feat: add scoped Oh store authorities --- README.md | 59 +- SECURITY.md | 17 +- dist/cli.js | 867 +++++++++++++++- dist/contract.d.ts | 5 + dist/contract.d.ts.map | 2 +- dist/index.d.ts | 1 + dist/index.d.ts.map | 2 +- dist/index.js | 597 ++++++++++- dist/libsql.d.ts | 32 + dist/libsql.d.ts.map | 1 + dist/libsql.js | 1608 ++++++++++++++++++++++++++++++ dist/sdk.js | 867 +++++++++++++++- dist/sqlite/driver.d.ts | 1 + dist/sqlite/driver.d.ts.map | 2 +- dist/sqlite/index.d.ts | 1 + dist/sqlite/index.d.ts.map | 2 +- dist/sqlite/index.js | 915 ++++++++++++++++- dist/sqlite/migrations.d.ts | 2 +- dist/sqlite/migrations.d.ts.map | 2 +- dist/sqlite/port.d.ts | 40 + dist/sqlite/port.d.ts.map | 1 + dist/sqlite/store.d.ts | 47 +- dist/sqlite/store.d.ts.map | 2 +- dist/store.d.ts | 221 ++++ dist/store.d.ts.map | 1 + dist/store.js | 957 ++++++++++++++++++ dist/sync.js | 26 +- package.json | 19 +- site/app/spec/page.tsx | 6 +- site/public/spec/README.md | 7 +- site/public/spec/v1/migration.md | 13 + site/public/spec/v1/storage.md | 35 +- site/public/spec/v1/store.md | 96 ++ spec/README.md | 7 +- spec/v1/migration.md | 13 + spec/v1/storage.md | 35 +- spec/v1/store.md | 96 ++ src/cli.test.ts | 2 +- src/contract.ts | 26 +- src/index.ts | 1 + src/libsql.test.ts | 185 ++++ src/libsql.ts | 655 ++++++++++++ src/sqlite/driver.ts | 12 + src/sqlite/index.ts | 1 + src/sqlite/migrations.test.ts | 44 + src/sqlite/migrations.ts | 28 +- src/sqlite/port.test.ts | 127 +++ src/sqlite/port.ts | 120 +++ src/sqlite/store.ts | 296 +++++- src/store.test.ts | 101 ++ src/store.ts | 673 +++++++++++++ tests/node-portable.mjs | 13 + tests/public-surface.test.ts | 6 +- 53 files changed, 8731 insertions(+), 164 deletions(-) create mode 100644 dist/libsql.d.ts create mode 100644 dist/libsql.d.ts.map create mode 100644 dist/libsql.js create mode 100644 dist/sqlite/port.d.ts create mode 100644 dist/sqlite/port.d.ts.map create mode 100644 dist/store.d.ts create mode 100644 dist/store.d.ts.map create mode 100644 dist/store.js create mode 100644 site/public/spec/v1/store.md create mode 100644 spec/v1/store.md create mode 100644 src/libsql.test.ts create mode 100644 src/libsql.ts create mode 100644 src/sqlite/migrations.test.ts create mode 100644 src/sqlite/port.test.ts create mode 100644 src/sqlite/port.ts create mode 100644 src/store.test.ts create mode 100644 src/store.ts create mode 100644 tests/node-portable.mjs diff --git a/README.md b/README.md index 42bd3b5..da88853 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,10 @@ indexes derived and replaceable. ## Install and first run -[Bun 1.3.14 or newer](https://bun.sh/docs/installation) is required. Install -the current immutable release directly from GitHub: +[Bun 1.3.14 or newer](https://bun.sh/docs/installation) is required for the +CLI, local SDK, and SQLite authority. The runtime-neutral store contracts and +direct libSQL authority also support Node 24 serverless runtimes. Install the +current immutable release directly from GitHub: ```sh bun add --global github:hraness/oh#v0.1.1 @@ -134,11 +136,59 @@ retry `OhConflictError` blindly. Read the new head and records, reconcile the intended change, then submit a new operation. The root entrypoint exports canonical JSON, ontology, schema, graph, operation, -and sync contracts. Use `@hraness/oh/sqlite` for the local store, -`@hraness/oh/sdk` for the `Oh` facade, `@hraness/oh/sync` for transport seams, +store, and sync contracts. Use `@hraness/oh/store` for the runtime-neutral +promise interface, `@hraness/oh/libsql` for a direct Node 24 or serverless +authority, `@hraness/oh/sqlite` for the local Bun store, `@hraness/oh/sdk` for +the local `Oh` facade, `@hraness/oh/sync` for transport seams, `@hraness/oh/projection` for recursive derived views, and `@hraness/oh/semantic` for the optional local embedding backend. +## Open a scoped working store + +Working memory uses the same V1 graph and operation bytes under a different +storage lifecycle. The host chooses and retains the realm binding. Application +code receives the promise-based store and keeps the host object that can purge +a working space out of agent tools. A model-facing adapter should expose strict +semantic ingress and bounded query methods, not generic commit or change-feed +access. + +```ts +import { createClient } from "@libsql/client"; +import { + bootstrapOhLibSqlAuthorityV1, + createOhLibSqlStoreAuthorityV1, +} from "@hraness/oh/libsql"; +import { OH_WORKING_STORE_PROFILE_V1 } from "@hraness/oh/store"; + +// Run once during deployment with a short-lived schema credential. +const schemaClient = createClient({ + authToken: process.env.OH_SCHEMA_TOKEN!, + url: process.env.OH_DATABASE_URL!, +}); +await bootstrapOhLibSqlAuthorityV1(schemaClient); +schemaClient.close(); + +// Runtime opens verify the schema and execute no DDL. +const runtimeClient = createClient({ + authToken: process.env.OH_RUNTIME_TOKEN!, + url: process.env.OH_DATABASE_URL!, +}); +const authority = await createOhLibSqlStoreAuthorityV1(runtimeClient, { + profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "tenant:example/thread:research", + spaceId: "thread:research", +}); + +const store = authority.store; +console.log(await store.head()); +``` + +The working profile disables operation replication. Dependency-closure export +remains available for explicit reviewed adoption. `purgeWorkingSpace` exists +only on `authority.host`; do not expose that object or raw database credentials +through a model tool. Read the [store-port specification](spec/v1/store.md) for +exact snapshot, change-feed, codec ingress, closure, and purge behavior. + ## Derive an exact projection The projection subpath is pure TypeScript and runs in Node 24 serverless @@ -369,6 +419,7 @@ document. The current contract is V1: - [Schema evolution](spec/v1/schema-evolution.md) - [Graph and operations](spec/v1/graph.md) - [SQLite storage](spec/v1/storage.md) +- [Store ports, profiles, and direct libSQL authority](spec/v1/store.md) - [Sync protocol](spec/v1/sync.md) - [Local embedding profile](spec/v1/embedding.md) - [Derived projections](spec/v1/projection.md) diff --git a/SECURITY.md b/SECURITY.md index d37b1c7..c564c17 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,8 +29,21 @@ problem that has already been fixed there. - Contract, operation, record, and bundle digests detect accidental or hostile mutation. They do not encrypt data or authenticate an actor. - The libSQL sync seam validates contract bytes and fast-forward history. The - client application remains responsible for transport security, credentials, - access control, tenant isolation, backups, and service configuration. + direct libSQL authority additionally enforces compare-and-swap batches and + exact realm/profile bindings. The client application remains responsible for + transport security, credentials, access control, tenant isolation, backups, + and service configuration. +- Direct libSQL schema creation is an explicit bootstrap API. Use a short-lived + schema credential for that step and a narrower data credential for runtime + opens, commits, reads, and host-controlled purge. +- A working-store purge removes rows reachable through the supported authority + and leaves a content-free receipt. It does not erase provider backups, + replicas outside that authority, exported dependency closures, logs created + by the host, or bytes copied by a process that already held raw credentials. + Match retention claims to the complete custody and backup system. +- Store profiles are host control metadata. V1 operation digests do not attest + to that profile, and callers with raw database or filesystem access remain + outside the profile API boundary. - Oh does not redact record values. Do not write secrets or sensitive research into a space unless the database, filesystem, backups, and sync destination have the required protection. diff --git a/dist/cli.js b/dist/cli.js index d55e94d..4952436 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -1026,10 +1026,13 @@ function parseOhContractManifestV1(value) { class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1046,6 +1049,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/operation.ts @@ -1348,6 +1372,553 @@ async function searchOhV1(input) { import { mkdirSync } from "fs"; import { dirname } from "path"; +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} + // src/sqlite/driver.ts import { existsSync } from "fs"; import { Database } from "bun:sqlite"; @@ -1409,9 +1980,22 @@ function withImmediateTransaction(database, work) { throw error; } } +function withReadTransaction(database, work) { + database.exec("BEGIN"); + try { + const result = work(); + database.exec("COMMIT"); + return result; + } catch (error) { + try { + database.exec("ROLLBACK"); + } catch {} + throw error; + } +} // src/sqlite/migrations.ts -var OH_SQLITE_SCHEMA_VERSION = 1; +var OH_SQLITE_SCHEMA_VERSION = 2; var OH_SQLITE_MIGRATIONS = Object.freeze([ Object.freeze({ name: "0001_oh_core", @@ -1520,6 +2104,32 @@ CREATE VIRTUAL TABLE oh_search_fts USING fts5( CREATE INDEX oh_operations_space_sequence ON oh_operations(space_id, sequence); CREATE INDEX oh_records_space_kind ON oh_records(space_id, kind, record_key); CREATE INDEX oh_dependencies_dependency ON oh_dependencies(space_id, dependency_key); +` + }), + Object.freeze({ + name: "0002_store_realms", + version: 2, + sql: ` +CREATE TABLE oh_space_bindings ( + space_id TEXT PRIMARY KEY REFERENCES oh_spaces(space_id), + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL CHECK(length(profile_sha256) = 64), + binding_sha256 TEXT NOT NULL UNIQUE CHECK(length(binding_sha256) = 64), + binding_json TEXT NOT NULL CHECK(json_valid(binding_json)), + created_at TEXT NOT NULL +) STRICT; + +CREATE TABLE oh_space_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL CHECK(length(binding_sha256) = 64), + prior_operation_sha256 TEXT CHECK(prior_operation_sha256 IS NULL OR length(prior_operation_sha256) = 64), + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE CHECK(length(receipt_sha256) = 64), + receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)) +) STRICT; ` }) ]); @@ -1556,28 +2166,7 @@ function applyOhSqliteMigrations(database) { } // src/sqlite/store.ts -var EMPTY_RECORDS_SHA256 = canonicalSha256([]); - -class OhConflictError extends Error { - constructor(message) { - super(message); - this.name = "OhConflictError"; - } -} - -class OhIntegrityError extends Error { - constructor(message) { - super(message); - this.name = "OhIntegrityError"; - } -} - -class OhDependencyError extends Error { - constructor(message) { - super(message); - this.name = "OhDependencyError"; - } -} +var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -1665,6 +2254,12 @@ class OhSqliteStore { if (this.#closed) throw new Error("The Oh store is closed."); } + #assertOperationReplication() { + const binding = this.binding(); + if (binding !== null && !binding.profile.capabilities.operationReplication) { + throw new OhProfileError("This bound store profile forbids operation replication."); + } + } #registerContract() { const manifestJson = canonicalJson(OH_CONTRACT_MANIFEST_V1); this.database.query(`INSERT INTO oh_contracts(contract_id, contract_sha256, manifest_json, created_at) @@ -1676,13 +2271,61 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); + const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); + if (purged !== null) { + let value; + try { + value = JSON.parse(purged.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { + throw new OhIntegrityError("A stored purge receipt is invalid."); + } + throw new OhPurgedSpaceError(receipt); + } const now = canonicalNow(); this.database.query(`INSERT INTO oh_spaces( space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now); + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); return this.head(); } + bind(bindingValue) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId) { + throw new OhProfileError("The store binding does not identify this space."); + } + const bindingJson = canonicalJson(binding); + this.database.query(`INSERT INTO oh_space_bindings( + space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== bindingJson) { + throw new OhProfileError("The space is already bound to a different realm or profile."); + } + return binding; + } + binding() { + this.#assertOpen(); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null) + return null; + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json) { + throw new OhIntegrityError("A stored binding is invalid."); + } + return binding; + } head() { this.#assertOpen(); const row = this.database.query(`SELECT generation, graph_revision_sha256, @@ -1838,6 +2481,7 @@ class OhSqliteStore { } importOperation(value) { this.#assertOpen(); + this.#assertOperationReplication(); const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); @@ -1863,6 +2507,7 @@ class OhSqliteStore { } exportOperations(afterSequence = 0, limit = 1000) { this.#assertOpen(); + this.#assertOperationReplication(); if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); @@ -1874,6 +2519,143 @@ class OhSqliteStore { return operation; }); } + #headAt(reference) { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) + throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) + return emptyOhHeadV1(); + const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + if (row === null) + throw new OhConflictError("The requested head is not present in this space."); + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + snapshotAtHead(options = {}) { + this.#assertOpen(); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + return withReadTransaction(this.database, () => { + const current = this.head(); + const target = options.head === undefined ? current : this.#headAt(options.head); + if (target.sequence > current.sequence) + throw new OhConflictError("The requested head is ahead of this space."); + const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); + const operations = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + if (canonicalJson(value) !== row.operation_json) + throw new OhIntegrityError("A stored operation is not canonical JSON."); + const operation = parseOhOperationV1(value); + if (operation === null) + throw new OhIntegrityError("A stored operation is invalid."); + return operation; + }); + const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Operation replay does not reproduce the requested head."); + } + return snapshot; + }); + } + changesSince(fromValue, options = {}) { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) + throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit, 100, 1000); + return withReadTransaction(this.database, () => { + const current = this.head(); + const fromHead = this.#headAt(from); + const through = options.through === undefined ? current : this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); + } + const rows = this.database.query(`SELECT operation_json FROM oh_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? + ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); + const parsed = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; + }); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + const first = operations[0]; + if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { + throw new OhIntegrityError("The change feed does not extend its cursor."); + } + for (let index = 1;index < operations.length; index += 1) { + const prior = operations[index - 1]; + const operation = operations[index]; + if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The change feed contains a gap or fork."); + } + } + const last = operations.at(-1); + const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; + return { + from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, + operations, + through, + to, + v: 1 + }; + }); + } + exportDependencyClosure(input) { + const binding = parseOhStoreBindingV1(input.binding); + if (binding === null || binding.spaceId !== this.spaceId || canonicalJson(this.binding()) !== canonicalJson(binding)) { + throw new OhProfileError("Dependency closure export requires the exact persisted store binding."); + } + if (!binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This store profile does not permit dependency-closure export."); + } + const snapshot = this.snapshotAtHead({ + ...input.head === undefined ? {} : { head: input.head }, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords } + }); + return createOhDependencyClosureV1({ + binding, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }, + roots: input.roots, + snapshot + }); + } get(key) { this.#assertOpen(); const parsedKey = safeCode(key, 512); @@ -1979,7 +2761,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = storedCount <= 1000 ? this.exportOperations(0, 1000) : this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { + const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { let value; try { value = JSON.parse(row.operation_json); @@ -1993,6 +2775,8 @@ class OhSqliteStore { throw new OhIntegrityError("A stored operation is invalid."); return parsed; }); + if (operations.length !== storedCount) + throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); } #verifyOperations(operations) { @@ -2002,7 +2786,7 @@ class OhSqliteStore { generation: 0, graphRevisionSha256: null, operationSha256: null, - recordsSha256: EMPTY_RECORDS_SHA256, + recordsSha256: EMPTY_RECORDS_SHA2562, sequence: 0, v: 1 }; @@ -2085,6 +2869,35 @@ class OhSqliteStore { contract() { return { manifest: OH_CONTRACT_MANIFEST_V1, sqliteSchemaVersion: OH_SQLITE_SCHEMA_VERSION }; } + purgeWorkingSpace(bindingValue, purgedAt = canonicalNow()) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + return withImmediateTransaction(this.database, () => { + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== canonicalJson(binding)) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); + this.database.query(`INSERT INTO oh_space_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(this.spaceId, binding.bindingSha256, receipt.priorHead.operationSha256, receipt.priorHead.sequence, receipt.purgedAt, receipt.receiptSha256, canonicalJson(receipt)); + this.database.query("DELETE FROM oh_search_fts WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_search_documents WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_dependencies WHERE space_id = ?").run(this.spaceId); + this.database.query(`DELETE FROM oh_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_operations WHERE space_id = ?)`).run(this.spaceId); + this.database.query("DELETE FROM oh_sync_outbox WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_sync_state WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_records WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + return receipt; + }); + } close() { if (this.#closed) return; diff --git a/dist/contract.d.ts b/dist/contract.d.ts index 5417639..60661be 100644 --- a/dist/contract.d.ts +++ b/dist/contract.d.ts @@ -24,5 +24,10 @@ export declare class OhRecordCodecRegistry { register(codec: OhRecordCodec): this; parse(kind: KnowledgeGraphRecordKindV1, value: unknown): JsonValue | null; has(kind: KnowledgeGraphRecordKindV1): boolean; + /** Parses only through an explicitly registered codec. */ + parseRequired(kind: KnowledgeGraphRecordKindV1, value: unknown): JsonValue | null; + /** Prevents the validation policy from changing after an ingress is created. */ + seal(): this; + get sealed(): boolean; } //# sourceMappingURL=contract.d.ts.map \ No newline at end of file diff --git a/dist/contract.d.ts.map b/dist/contract.d.ts.map index e7fb257..8b7ecea 100644 --- a/dist/contract.d.ts.map +++ b/dist/contract.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,0BAA0B,EACjC,KAAK,0BAA0B,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,2BAA2B,EAAE,MAAM,UAAU,CAAC;AAEvD,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,UAAU,EAAE,OAAO,iBAAiB,CAAC;IACrC,cAAc,EAAE,SAAS,CAAC;IAC1B,kBAAkB,EAAE,OAAO,0BAA0B,CAAC;IACtD,eAAe,EAAE,OAAO,sBAAsB,CAAC;IAC/C,WAAW,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACnD,mBAAmB,EAAE,OAAO,2BAA2B,CAAC;IACxD,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAWH,eAAO,MAAM,uBAAuB,EAAE,oBAGpC,CAAC;AAEH,uEAAuE;AACvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,oBAAoB,GAAG,IAAI,CAKrF;AAED,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,SAAS,GAAG,SAAS,IAAI,QAAQ,CAAC;IACpE,IAAI,EAAE,0BAA0B,CAAC;IACjC,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC;CACjC,CAAC,CAAC;AAEH,uFAAuF;AACvF,qBAAa,qBAAqB;;IAGhC,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAMpC,KAAK,CAAC,IAAI,EAAE,0BAA0B,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI;IAMzE,GAAG,CAAC,IAAI,EAAE,0BAA0B,GAAG,OAAO;CAG/C"} \ No newline at end of file +{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,0BAA0B,EACjC,KAAK,0BAA0B,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,2BAA2B,EAAE,MAAM,UAAU,CAAC;AAEvD,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,UAAU,EAAE,OAAO,iBAAiB,CAAC;IACrC,cAAc,EAAE,SAAS,CAAC;IAC1B,kBAAkB,EAAE,OAAO,0BAA0B,CAAC;IACtD,eAAe,EAAE,OAAO,sBAAsB,CAAC;IAC/C,WAAW,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACnD,mBAAmB,EAAE,OAAO,2BAA2B,CAAC;IACxD,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAWH,eAAO,MAAM,uBAAuB,EAAE,oBAGpC,CAAC;AAEH,uEAAuE;AACvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,oBAAoB,GAAG,IAAI,CAKrF;AAED,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,SAAS,GAAG,SAAS,IAAI,QAAQ,CAAC;IACpE,IAAI,EAAE,0BAA0B,CAAC;IACjC,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC;CACjC,CAAC,CAAC;AAEH,uFAAuF;AACvF,qBAAa,qBAAqB;;IAIhC,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAOpC,KAAK,CAAC,IAAI,EAAE,0BAA0B,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI;IAMzE,GAAG,CAAC,IAAI,EAAE,0BAA0B,GAAG,OAAO;IAI9C,0DAA0D;IAC1D,aAAa,CAAC,IAAI,EAAE,0BAA0B,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI;IAWjF,gFAAgF;IAChF,IAAI,IAAI,IAAI;IAKZ,IAAI,MAAM,IAAI,OAAO,CAEpB;CACF"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 2a67ea0..a74a3fe 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -4,5 +4,6 @@ export * from "./graph"; export * from "./ontology"; export * from "./operation"; export * from "./schema"; +export * from "./store"; export * from "./sync"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 1480dbe..12a339d 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,QAAQ,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 88ab85d..12ef854 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1024,10 +1024,13 @@ function parseOhContractManifestV1(value) { class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1044,6 +1047,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/operation.ts @@ -1295,18 +1319,573 @@ function createLibSqlOperationSyncTransportV1(client) { } }; } +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} export { + verifyOhDependencyClosureV1, verifyKnowledgeValueV1, verifyKnowledgeSchemaEvolutionV1, utf8ByteLength, + transitionOhSnapshotV1, synchronizeOhStoreV1, sortUnique, sha256Hex, safeCode, + replayOhOperationsV1, reduceKnowledgeGraphRevisionsV1, parseSha256Hex, parseOhSyncBundleV1, + parseOhStoreProfileV1, + parseOhStoreBindingV1, + parseOhSpacePurgeReceiptV1, parseOhOperationV1, + parseOhHeadV1, + parseOhHeadRefV1, + parseOhDependencyClosureV1, parseOhContractManifestV1, parseKnowledgeVocabularyRevisionV1, parseKnowledgeValueV1, @@ -1334,8 +1913,13 @@ export { isPlainRecord, hasExactKeys, graphRevisionSha256V1, + emptyOhHeadV1, createOhSyncBundleV1, + createOhStoreProfileV1, + createOhStoreBindingV1, + createOhSpacePurgeReceiptV1, createOhOperationV1, + createOhDependencyClosureV1, createLibSqlOperationSyncTransportV1, createKnowledgeVocabularyRevisionV1, createKnowledgeStatementV1, @@ -1353,7 +1937,14 @@ export { canonicalJson, boundedText, OhValidationError, + OhSemanticBundleIngressV1, OhRecordCodecRegistry, + OhPurgedSpaceError, + OhProfileError, + OhIntegrityError, + OhDependencyError, + OhConflictError, + OH_WORKING_STORE_PROFILE_V1, OH_SYNC_PROTOCOL_V1, OH_SCHEMA_KINDS_V1, OH_SCHEMA_FORMAT_VERSION_V1, @@ -1369,6 +1960,8 @@ export { OH_KNOWLEDGE_ACTIVITY_KINDS_V1, OH_GRAPH_LIMITS_V1, OH_GRAPH_FORMAT_VERSION_V1, + OH_DEPENDENCY_CLOSURE_LIMITS_V1, OH_CONTRACT_MANIFEST_V1, - OH_CONTRACT_ID_V1 + OH_CONTRACT_ID_V1, + OH_CANONICAL_STORE_PROFILE_V1 }; diff --git a/dist/libsql.d.ts b/dist/libsql.d.ts new file mode 100644 index 0000000..0ff11a5 --- /dev/null +++ b/dist/libsql.d.ts @@ -0,0 +1,32 @@ +import { type Sha256Hex } from "./canonical"; +import { type OhStoreAuthorityV1, type OhStoreProfileV1 } from "./store"; +export type OhLibSqlValueV1 = ArrayBuffer | Date | Uint8Array | bigint | boolean | null | number | string; +export type OhLibSqlStatementV1 = Readonly<{ + args?: readonly OhLibSqlValueV1[]; + sql: string; +}>; +export type OhLibSqlResultV1 = Readonly<{ + rows: readonly (Readonly> | readonly unknown[])[]; + rowsAffected?: number; +}>; +/** Structural subset implemented by `@libsql/client` clients. */ +export interface OhLibSqlClientV1 { + batch(statements: readonly OhLibSqlStatementV1[], mode?: "deferred" | "read" | "write"): Promise; + close?(): void; + execute(statement: OhLibSqlStatementV1 | string): Promise; +} +export type OhLibSqlStoreAuthorityOptionsV1 = Readonly<{ + closeClient?: boolean; + profile?: OhStoreProfileV1; + realmId?: string; + spaceId?: string; +}>; +/** One-time schema operation for a client authorized to create authority tables. */ +export declare function bootstrapOhLibSqlAuthorityV1(client: OhLibSqlClientV1): Promise>; +/** Opens a direct libSQL/Turso authority; this is not operation-log sync. */ +export declare function createOhLibSqlStoreAuthorityV1(client: OhLibSqlClientV1, options?: OhLibSqlStoreAuthorityOptionsV1): Promise; +//# sourceMappingURL=libsql.d.ts.map \ No newline at end of file diff --git a/dist/libsql.d.ts.map b/dist/libsql.d.ts.map new file mode 100644 index 0000000..ff6fa3f --- /dev/null +++ b/dist/libsql.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"libsql.d.ts","sourceRoot":"","sources":["../src/libsql.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAKrB,OAAO,EAuBL,KAAK,kBAAkB,EAGvB,KAAK,gBAAgB,EAGtB,MAAM,SAAS,CAAC;AAEjB,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,IAAI,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAC1G,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IAAE,IAAI,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAC/F,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,SAAS,OAAO,EAAE,CAAC,EAAE,CAAC;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC,CAAC;AAEH,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CACH,UAAU,EAAE,SAAS,mBAAmB,EAAE,EAC1C,IAAI,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,OAAO,GACnC,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CAAC;IACxC,KAAK,CAAC,IAAI,IAAI,CAAC;IACf,OAAO,CAAC,SAAS,EAAE,mBAAmB,GAAG,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CAC7E;AAED,MAAM,MAAM,+BAA+B,GAAG,QAAQ,CAAC;IACrD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAmKH,oFAAoF;AACpF,wBAAsB,4BAA4B,CAChD,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,QAAQ,CAAC;IAAE,YAAY,EAAE,SAAS,CAAC;IAAC,aAAa,EAAE,CAAC,CAAC;IAAC,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC,CAAC,CAuBxE;AAsWD,6EAA6E;AAC7E,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAsC7B"} \ No newline at end of file diff --git a/dist/libsql.js b/dist/libsql.js new file mode 100644 index 0000000..3917d40 --- /dev/null +++ b/dist/libsql.js @@ -0,0 +1,1608 @@ +// src/canonical.ts +import { createHash, randomBytes } from "node:crypto"; + +class OhValidationError extends Error { + code; + path; + constructor(code, path, message) { + super(`${path}: ${message}`); + this.name = "OhValidationError"; + this.code = code; + this.path = path; + } +} +function isPlainRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasExactKeys(value, keys) { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function assertUnicodeScalarString(value, path) { + 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)) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + index += 1; + } else if (code >= 56320 && code <= 57343) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + } +} +function encodeCanonical(value, path, ancestors) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + assertUnicodeScalarString(value, path); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new OhValidationError("non-json-number", path, "must be finite"); + } + if (Object.is(value, -0)) { + throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null) { + throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`); + } + if (ancestors.has(value)) { + throw new OhValidationError("cycle", path, "contains a cycle"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes"); + } + encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors)); + } + const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)); + if (extraKeys.length > 0) { + throw new OhValidationError("non-json-property", path, "array has non-index properties"); + } + return `[${encoded.join(",")}]`; + } + if (!isPlainRecord(value)) { + throw new OhValidationError("non-plain-object", path, "must be a plain object"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new OhValidationError("non-json-property", path, "object has a symbol property"); + } + const keys = ownKeys; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property"); + } + } + keys.sort(); + const entries = keys.map((key) => { + assertUnicodeScalarString(key, `${path}.`); + return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`; + }); + return `{${entries.join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function canonicalJson(value) { + return encodeCanonical(value, "$", new Set); +} +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} +function canonicalSha256(value) { + return sha256Hex(canonicalJson(value)); +} +function parseSha256Hex(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +} +function parseCanonicalInstantV1(value) { + if (typeof value !== "string" || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null; +} +function canonicalNow() { + return new Date().toISOString(); +} +function safeCode(value, maximumLength = 128) { + return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function orderedUnique(values, key) { + return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); +} +function sortUnique(values, key) { + const sorted = [...values].sort((left, right) => { + const leftKey = key(left); + const rightKey = key(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + if (!orderedUnique(sorted, key)) { + throw new OhValidationError("duplicate", "$", "contains duplicate canonical values"); + } + return sorted; +} + +// src/graph.ts +var OH_GRAPH_FORMAT_VERSION_V1 = 1; +var OH_GRAPH_LIMITS_V1 = Object.freeze({ + changesPerOperation: 8192, + dependenciesPerRecord: 4096, + recordBytes: 1024 * 1024, + recordsPerSnapshot: 65536 +}); +var OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1 = [ + "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 recordKey(value) { + return typeof value === "string" && value.length <= 512 && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function createKnowledgeGraphRecordV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"]) || input.v !== 1 || !Array.isArray(input.dependencies)) + throw new TypeError("Invalid graph record input."); + const key = recordKey(input.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind); + if (key === null || kind === undefined || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) + throw new TypeError("Invalid graph record identity."); + const dependencies = input.dependencies.map(recordKey); + if (dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) { + throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive."); + } + const valueJson = canonicalJson(input.value); + if (Buffer.byteLength(valueJson, "utf8") > OH_GRAPH_LIMITS_V1.recordBytes) { + throw new RangeError("Graph record value exceeds its canonical byte limit."); + } + const payload = { dependencies, key, kind, v: 1, value: input.value }; + return { ...payload, recordSha256: canonicalSha256(payload) }; +} +function parseKnowledgeGraphRecordV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) + return null; + const recordSha256 = parseSha256Hex(value.recordSha256); + const { recordSha256: _digest, ...input } = value; + try { + const created = createKnowledgeGraphRecordV1(input); + return recordSha256 !== null && created.recordSha256 === recordSha256 ? { ...created, recordSha256 } : null; + } catch { + return null; + } +} +function knowledgeGraphRecordRefV1(record) { + return { + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + sha256: record.recordSha256, + v: 1 + }; +} +function changeKey(change) { + return change.kind === "put" ? change.record.key : change.key; +} +function canonicalKnowledgeGraphChangesV1(changes) { + const normalized = []; + for (const change of changes) { + if (!isPlainRecord(change) || change.v !== 1) + throw new TypeError("Invalid graph change."); + if (change.kind === "put") { + const record = parseKnowledgeGraphRecordV1(change.record); + if (record === null) + throw new TypeError("Invalid graph record in change."); + normalized.push({ kind: "put", record, v: 1 }); + } else if (change.kind === "tombstone") { + const key = recordKey(change.key); + const priorSha256 = parseSha256Hex(change.priorSha256); + if (key === null || priorSha256 === null) + throw new TypeError("Invalid graph tombstone."); + normalized.push({ key, kind: "tombstone", priorSha256, v: 1 }); + } else + throw new TypeError("Unknown graph change kind."); + } + return sortUnique(normalized, changeKey); +} +function graphRevisionSha256V1(input) { + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + const operationId = safeCode(input.operationId); + const parentGraphRevisionSha256 = input.parentGraphRevisionSha256 === null ? null : parseSha256Hex(input.parentGraphRevisionSha256); + const recordsSha256 = parseSha256Hex(input.recordsSha256); + const revision = Number.isSafeInteger(input.revision) && input.revision > 0 ? input.revision : null; + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation || operationId === null || recordsSha256 === null || revision === null || input.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || revision === 1 !== (parentGraphRevisionSha256 === null)) { + throw new TypeError("Invalid graph revision digest input."); + } + return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); +} + +// src/ontology.ts +var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; +var OH_CONTRACT_ID_V1 = "oh.ontology.v1"; +var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ + dimensions: 64, + listValues: 256, + qualifiers: 128, + statementBytes: 256 * 1024, + textBytes: 64 * 1024 +}); + +// src/schema.ts +var OH_SCHEMA_FORMAT_VERSION_V1 = 1; + +// src/contract.ts +var manifestPayload = Object.freeze({ + contractId: OH_CONTRACT_ID_V1, + graphFormatVersion: OH_GRAPH_FORMAT_VERSION_V1, + ontologyVersion: OH_ONTOLOGY_VERSION_V1, + recordKinds: OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + schemaFormatVersion: OH_SCHEMA_FORMAT_VERSION_V1, + v: 1 +}); +var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ + ...manifestPayload, + contractSha256: canonicalSha256(manifestPayload) +}); +class OhRecordCodecRegistry { + #codecs = new Map; + #sealed = false; + register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); + if (this.#codecs.has(codec.kind)) + throw new TypeError(`A codec is already registered for ${codec.kind}.`); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); + return this; + } + parse(kind, value) { + const codec = this.#codecs.get(kind); + if (codec !== undefined) + return codec.parse(value); + try { + canonicalJson(value); + return value; + } catch { + return null; + } + } + has(kind) { + return this.#codecs.has(kind); + } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } +} + +// src/operation.ts +var OH_OPERATION_MAX_BYTES_V1 = 64 * 1024 * 1024; +function parsePayload(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "changes", + "contractId", + "graphRevisionSha256", + "instant", + "operationId", + "parentOperationSha256", + "recordsSha256", + "sequence", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractId !== OH_CONTRACT_ID_V1 || !Array.isArray(value.changes)) + return null; + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const spaceId = safeCode(value.spaceId); + const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); + const parentOperationSha256 = value.parentOperationSha256 === null ? null : parseSha256Hex(value.parentOperationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const instant = parseCanonicalInstantV1(value.instant); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence > 0 ? value.sequence : null; + let changes; + try { + changes = canonicalKnowledgeGraphChangesV1(value.changes); + } catch { + return null; + } + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) + return null; + return actorId !== null && operationId !== null && spaceId !== null && graphRevisionSha256 !== null && recordsSha256 !== null && instant !== null && sequence !== null && (value.parentOperationSha256 === null || parentOperationSha256 !== null) && sequence === 1 === (parentOperationSha256 === null) ? { + actorId, + changes, + contractId: OH_CONTRACT_ID_V1, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256, + recordsSha256, + sequence, + spaceId, + v: 1 + } : null; +} +function createOhOperationV1(input) { + const payload = parsePayload(input); + if (payload === null) + throw new TypeError("Invalid Oh operation payload."); + const operation = { ...payload, operationSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(operation), "utf8") > OH_OPERATION_MAX_BYTES_V1) { + throw new RangeError("Oh operation exceeds its canonical byte limit."); + } + return operation; +} +function parseOhOperationV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "operationSha256")) + return null; + const operationSha256 = parseSha256Hex(value.operationSha256); + const { operationSha256: _digest, ...input } = value; + const payload = parsePayload(input); + return operationSha256 !== null && payload !== null && Buffer.byteLength(canonicalJson({ ...payload, operationSha256 }), "utf8") <= OH_OPERATION_MAX_BYTES_V1 && canonicalSha256(payload) === operationSha256 ? { ...payload, operationSha256 } : null; +} + +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} + +// src/libsql.ts +var AUTHORITY_SCHEMA_NAME = "oh.libsql-authority.v1"; +var AUTHORITY_SCHEMA_VERSION = 1; +var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); +var AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ + `CREATE TABLE IF NOT EXISTS oh_authority_contracts ( + contract_id TEXT PRIMARY KEY, + contract_sha256 TEXT NOT NULL, + manifest_json TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_spaces ( + space_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK(generation >= 0), + head_operation_sha256 TEXT, + graph_revision_sha256 TEXT, + records_sha256 TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK(generation = sequence) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_operations ( + operation_sha256 TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence > 0), + operation_id TEXT NOT NULL, + parent_operation_sha256 TEXT, + graph_revision_sha256 TEXT NOT NULL, + records_sha256 TEXT NOT NULL, + operation_json TEXT NOT NULL, + instant TEXT NOT NULL, + UNIQUE(space_id, sequence), + UNIQUE(space_id, operation_id) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_operation_records ( + operation_sha256 TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK(ordinal >= 0), + record_key TEXT NOT NULL, + change_kind TEXT NOT NULL CHECK(change_kind IN ('put', 'tombstone')), + record_sha256 TEXT NOT NULL, + PRIMARY KEY(operation_sha256, ordinal), + UNIQUE(operation_sha256, record_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_records ( + space_id TEXT NOT NULL, + record_key TEXT NOT NULL, + kind TEXT NOT NULL, + record_sha256 TEXT NOT NULL, + record_json TEXT NOT NULL, + operation_sha256 TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence > 0), + PRIMARY KEY(space_id, record_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_dependencies ( + space_id TEXT NOT NULL, + record_key TEXT NOT NULL, + dependency_key TEXT NOT NULL, + PRIMARY KEY(space_id, record_key, dependency_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_bindings ( + space_id TEXT PRIMARY KEY, + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL, + binding_sha256 TEXT NOT NULL UNIQUE, + binding_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL, + prior_operation_sha256 TEXT, + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE, + receipt_json TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_commit_guards ( + value TEXT NOT NULL CHECK(value = 'ok') + ) STRICT`, + "CREATE INDEX IF NOT EXISTS oh_authority_operations_space_sequence ON oh_authority_operations(space_id, sequence)", + "CREATE INDEX IF NOT EXISTS oh_authority_records_space_kind ON oh_authority_records(space_id, kind, record_key)", + "CREATE INDEX IF NOT EXISTS oh_authority_dependencies_dependency ON oh_authority_dependencies(space_id, dependency_key)" +]); +var AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_STATEMENTS); +function rowValue(row, key, index) { + return Array.isArray(row) ? row[index] : row[key]; +} +function integer(value) { + const parsed = typeof value === "bigint" ? Number(value) : Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} +function normalizeLimit(value, fallback = 100, maximum = 1000) { + const limit = value ?? fallback; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { + throw new RangeError(`limit must be an integer from 1 through ${maximum}.`); + } + return limit; +} +function parseOperationJson(value) { + if (typeof value !== "string") + throw new OhIntegrityError("A stored operation is not JSON text."); + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(parsedValue); + if (operation === null || canonicalJson(operation) !== value) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; +} +function parseHeadRow(row) { + const generation = integer(rowValue(row, "generation", 0)); + const graphValue = rowValue(row, "graph_revision_sha256", 1); + const operationValue = rowValue(row, "head_operation_sha256", 2); + const recordsSha256 = parseSha256Hex(rowValue(row, "records_sha256", 3)); + const sequence = integer(rowValue(row, "sequence", 4)); + const graphRevisionSha256 = graphValue === null ? null : parseSha256Hex(graphValue); + const operationSha256 = operationValue === null ? null : parseSha256Hex(operationValue); + if (generation === null || sequence === null || generation !== sequence || recordsSha256 === null || graphValue !== null && graphRevisionSha256 === null || operationValue !== null && operationSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null)) { + throw new OhIntegrityError("The remote authority contains an invalid head."); + } + return { generation, graphRevisionSha256, operationSha256, recordsSha256, sequence, v: 1 }; +} +async function queryOne(client, statement) { + return (await client.execute(statement)).rows[0] ?? null; +} +async function verifyAuthoritySchema(client) { + const installed = await queryOne(client, { sql: `SELECT name, schema_sha256 + FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); + if (installed === null || rowValue(installed, "name", 0) !== AUTHORITY_SCHEMA_NAME || rowValue(installed, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256) { + throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + } + const contract = await queryOne(client, { sql: `SELECT contract_sha256, manifest_json + FROM oh_authority_contracts WHERE contract_id = ?`, args: [OH_CONTRACT_MANIFEST_V1.contractId] }); + if (contract === null || rowValue(contract, "contract_sha256", 0) !== OH_CONTRACT_MANIFEST_V1.contractSha256 || rowValue(contract, "manifest_json", 1) !== canonicalJson(OH_CONTRACT_MANIFEST_V1)) { + throw new OhIntegrityError("The remote authority contract differs from this runtime."); + } +} +async function bootstrapOhLibSqlAuthorityV1(client) { + await client.execute(`CREATE TABLE IF NOT EXISTS oh_authority_schemas ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + schema_sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL + ) STRICT`); + const applied = await queryOne(client, { sql: `SELECT name, schema_sha256 + FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); + if (applied !== null && (rowValue(applied, "name", 0) !== AUTHORITY_SCHEMA_NAME || rowValue(applied, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256)) { + throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + } + const setup = AUTHORITY_SCHEMA_STATEMENTS.map((sql) => ({ sql })); + setup.push({ + sql: `INSERT INTO oh_authority_schemas(version, name, schema_sha256, applied_at) + VALUES (?, ?, ?, ?) ON CONFLICT(version) DO NOTHING`, + args: [AUTHORITY_SCHEMA_VERSION, AUTHORITY_SCHEMA_NAME, AUTHORITY_SCHEMA_SHA256, canonicalNow()] + }); + setup.push({ sql: `INSERT INTO oh_authority_contracts(contract_id, contract_sha256, manifest_json) + VALUES (?, ?, ?) ON CONFLICT(contract_id) DO NOTHING`, args: [ + OH_CONTRACT_MANIFEST_V1.contractId, + OH_CONTRACT_MANIFEST_V1.contractSha256, + canonicalJson(OH_CONTRACT_MANIFEST_V1) + ] }); + await client.batch(setup, "write"); + await verifyAuthoritySchema(client); + return { schemaSha256: AUTHORITY_SCHEMA_SHA256, schemaVersion: 1, v: 1 }; +} +async function initializeSpace(client, binding) { + const purged = await queryOne(client, { + sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + args: [binding.spaceId] + }); + if (purged !== null) { + const json = rowValue(purged, "receipt_json", 0); + if (typeof json !== "string") + throw new OhIntegrityError("A remote purge receipt is invalid."); + const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); + if (receipt === null || canonicalJson(receipt) !== json) + throw new OhIntegrityError("A remote purge receipt is invalid."); + throw new OhPurgedSpaceError(receipt); + } + const now = canonicalNow(); + await client.batch([ + { + sql: `INSERT INTO oh_authority_spaces(space_id, contract_id, generation, + head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at) + VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`, + args: [binding.spaceId, OH_CONTRACT_MANIFEST_V1.contractId, EMPTY_RECORDS_SHA2562, now, now] + }, + { + sql: `INSERT INTO oh_authority_bindings(space_id, realm_id, profile_id, profile_kind, + profile_sha256, binding_sha256, binding_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`, + args: [ + binding.spaceId, + binding.realmId, + binding.profile.profileId, + binding.profile.profileKind, + binding.profile.profileSha256, + binding.bindingSha256, + canonicalJson(binding), + now + ] + } + ], "write"); + const persisted = await queryOne(client, { + sql: "SELECT binding_json FROM oh_authority_bindings WHERE space_id = ?", + args: [binding.spaceId] + }); + if (persisted === null || rowValue(persisted, "binding_json", 0) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is already bound to a different realm or profile."); + } +} + +class OhLibSqlStoreV1 { + binding; + #client; + #closeClient; + #closed = false; + #purged = null; + constructor(client, binding, closeClient) { + this.#client = client; + this.binding = binding; + this.#closeClient = closeClient; + } + #assertOpen() { + if (this.#purged !== null) + throw new OhPurgedSpaceError(this.#purged); + if (this.#closed) + throw new Error("The Oh libSQL store is closed."); + } + async head() { + this.#assertOpen(); + const row = await queryOne(this.#client, { + sql: `SELECT generation, graph_revision_sha256, + head_operation_sha256, records_sha256, sequence FROM oh_authority_spaces WHERE space_id = ?`, + args: [this.binding.spaceId] + }); + if (row === null) { + const purged = await this.#readPurge(); + if (purged !== null) { + this.#purged = purged; + throw new OhPurgedSpaceError(purged); + } + throw new OhIntegrityError("The remote Oh space does not exist."); + } + return parseHeadRow(row); + } + async#readPurge() { + const row = await queryOne(this.#client, { + sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + args: [this.binding.spaceId] + }); + if (row === null) + return null; + const json = rowValue(row, "receipt_json", 0); + if (typeof json !== "string") + throw new OhIntegrityError("A remote purge receipt is invalid."); + const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); + if (receipt === null || canonicalJson(receipt) !== json) + throw new OhIntegrityError("A remote purge receipt is invalid."); + return receipt; + } + async#headAt(reference) { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) + throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) + return emptyOhHeadV1(); + const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, parsed.sequence] }); + if (row === null) + throw new OhConflictError("The requested head is not present in this space."); + const operation = parseOperationJson(rowValue(row, "operation_json", 0)); + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + async snapshot(options = {}) { + this.#assertOpen(); + const current = await this.head(); + const target = options.head === undefined ? current : await this.#headAt(options.head); + if (target.sequence > current.sequence) + throw new OhConflictError("The requested head is ahead of this space."); + const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`, args: [this.binding.spaceId, target.sequence] })).rows; + const operations = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, options.maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Remote operation replay does not reproduce the requested head."); + } + return snapshot; + } + async changesSince(fromValue, options = {}) { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) + throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit); + const current = await this.head(); + const fromHead = await this.#headAt(from); + const through = options.through === undefined ? current : await this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one remote history prefix."); + } + const rows = (await this.#client.execute({ + sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, + args: [this.binding.spaceId, fromHead.sequence, through.sequence, limit + 1] + })).rows; + const parsed = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + let prior = fromHead; + for (const operation of operations) { + if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The remote change feed contains a gap or fork."); + } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + return { + from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, + operations, + through, + to: prior, + v: 1 + }; + } + async#assertMaterializedSnapshot(snapshot) { + const rows = (await this.#client.execute({ sql: `SELECT record_json FROM oh_authority_records + WHERE space_id = ? ORDER BY record_key`, args: [this.binding.spaceId] })).rows; + const records = rows.map((row) => { + const json = rowValue(row, "record_json", 0); + if (typeof json !== "string") + throw new OhIntegrityError("A materialized remote record is not JSON text."); + const parsed = parseKnowledgeGraphRecordV1(JSON.parse(json)); + if (parsed === null || canonicalJson(parsed) !== json) + throw new OhIntegrityError("A materialized remote record is invalid."); + return parsed; + }); + if (canonicalJson(records) !== canonicalJson(snapshot.records)) { + throw new OhIntegrityError("Remote materialized records do not match operation replay."); + } + const dependencyRows = (await this.#client.execute({ + sql: `SELECT record_key, dependency_key + FROM oh_authority_dependencies WHERE space_id = ? ORDER BY record_key, dependency_key`, + args: [this.binding.spaceId] + })).rows.map((row) => ({ + dependency_key: rowValue(row, "dependency_key", 1), + record_key: rowValue(row, "record_key", 0) + })); + const expectedDependencies = snapshot.records.flatMap((record) => record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key }))); + if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { + throw new OhIntegrityError("Remote materialized dependencies do not match operation replay."); + } + } + async#operationById(operationId) { + const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); + return row === null ? null : parseOperationJson(rowValue(row, "operation_json", 0)); + } + async commit(input) { + this.#assertOpen(); + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || changes.length === 0) + throw new TypeError("Invalid Oh commit input."); + const duplicate = await this.#operationById(operationId); + if (duplicate !== null) { + if (duplicate.actorId !== actorId || canonicalJson(duplicate.changes) !== canonicalJson(changes)) { + throw new OhConflictError("The operation ID is already bound to different content."); + } + return duplicate; + } + const current = await this.head(); + if (!Number.isSafeInteger(input.expectedHead.generation) || input.expectedHead.generation < 0 || current.generation !== input.expectedHead.generation || current.operationSha256 !== input.expectedHead.operationSha256) { + throw new OhConflictError("The expected head does not match the current remote space head."); + } + const snapshot = await this.snapshot({ head: current }); + await this.#assertMaterializedSnapshot(snapshot); + const transition = transitionOhSnapshotV1({ + actorId, + changes, + instant: input.instant ?? canonicalNow(), + operationId, + snapshot, + spaceId: this.binding.spaceId + }); + const operation = transition.operation; + const existsOperation = "EXISTS (SELECT 1 FROM oh_authority_operations WHERE operation_sha256 = ?)"; + const statements = [{ + sql: `INSERT INTO oh_authority_operations(operation_sha256, space_id, sequence, + operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, + operation_json, instant) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ?)`, + args: [ + operation.operationSha256, + this.binding.spaceId, + operation.sequence, + operation.operationId, + operation.parentOperationSha256, + operation.graphRevisionSha256, + operation.recordsSha256, + canonicalJson(operation), + operation.instant, + this.binding.spaceId, + current.generation, + current.operationSha256 + ] + }]; + for (const [ordinal, change] of operation.changes.entries()) { + const key = change.kind === "put" ? change.record.key : change.key; + const digest = change.kind === "put" ? change.record.recordSha256 : change.priorSha256; + statements.push({ + sql: `INSERT INTO oh_authority_operation_records(operation_sha256, + ordinal, record_key, change_kind, record_sha256) + SELECT ?, ?, ?, ?, ? WHERE ${existsOperation}`, + args: [operation.operationSha256, ordinal, key, change.kind, digest, operation.operationSha256] + }); + statements.push({ sql: `DELETE FROM oh_authority_dependencies WHERE space_id = ? AND record_key = ? + AND ${existsOperation}`, args: [this.binding.spaceId, key, operation.operationSha256] }); + if (change.kind === "put") { + statements.push({ + sql: `INSERT INTO oh_authority_records(space_id, record_key, kind, + record_sha256, record_json, operation_sha256, sequence) + SELECT ?, ?, ?, ?, ?, ?, ? WHERE ${existsOperation} + ON CONFLICT(space_id, record_key) DO UPDATE SET kind = excluded.kind, + record_sha256 = excluded.record_sha256, record_json = excluded.record_json, + operation_sha256 = excluded.operation_sha256, sequence = excluded.sequence`, + args: [ + this.binding.spaceId, + key, + change.record.kind, + change.record.recordSha256, + canonicalJson(change.record), + operation.operationSha256, + operation.sequence, + operation.operationSha256 + ] + }); + } else { + statements.push({ + sql: `DELETE FROM oh_authority_records WHERE space_id = ? AND record_key = ? + AND record_sha256 = ? AND ${existsOperation}`, + args: [this.binding.spaceId, key, change.priorSha256, operation.operationSha256] + }); + } + } + for (const change of operation.changes) { + if (change.kind !== "put") + continue; + for (const dependency of change.record.dependencies) { + statements.push({ + sql: `INSERT INTO oh_authority_dependencies(space_id, record_key, dependency_key) + SELECT ?, ?, ? WHERE ${existsOperation}`, + args: [this.binding.spaceId, change.record.key, dependency, operation.operationSha256] + }); + } + } + statements.push({ + sql: `UPDATE oh_authority_spaces SET generation = ?, head_operation_sha256 = ?, + graph_revision_sha256 = ?, records_sha256 = ?, sequence = ?, updated_at = ? + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ? AND ${existsOperation}`, + args: [ + operation.sequence, + operation.operationSha256, + operation.graphRevisionSha256, + operation.recordsSha256, + operation.sequence, + operation.instant, + this.binding.spaceId, + current.generation, + current.operationSha256, + operation.operationSha256 + ] + }); + statements.push({ + sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE NOT EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 = ?)`, + args: [this.binding.spaceId, operation.sequence, operation.operationSha256] + }); + try { + await this.#client.batch(statements, "write"); + } catch (error) { + const raced = await this.#operationById(operationId); + if (raced !== null && raced.actorId === actorId && canonicalJson(raced.changes) === canonicalJson(changes)) + return raced; + const head = await this.head(); + if (head.operationSha256 !== current.operationSha256) { + throw new OhConflictError("The remote space head changed while committing."); + } + throw error; + } + const persisted = await this.#operationById(operationId); + if (persisted === null || canonicalJson(persisted) !== canonicalJson(operation)) { + throw new OhIntegrityError("The remote authority did not persist the committed operation exactly."); + } + return persisted; + } + async exportDependencyClosure(input) { + if (!this.binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This remote profile does not permit dependency-closure export."); + } + const snapshot = await this.snapshot({ + ...input.head === undefined ? {} : { head: input.head }, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords } + }); + return createOhDependencyClosureV1({ + binding: this.binding, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }, + roots: input.roots, + snapshot + }); + } + async verify() { + this.#assertOpen(); + const snapshot = await this.snapshot(); + await this.#assertMaterializedSnapshot(snapshot); + const countRow = await queryOne(this.#client, { sql: `SELECT count(*) AS count + FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] }); + const operations = countRow === null ? null : integer(rowValue(countRow, "count", 0)); + if (operations === null || operations !== snapshot.head.sequence) { + throw new OhIntegrityError("Remote operation count does not match its head."); + } + return { + head: snapshot.head, + integrity: "verified", + operations, + records: snapshot.records.length, + v: 1 + }; + } + async purgeWorkingSpace(purgedAt) { + this.#assertOpen(); + if (this.binding.profile.profileKind !== "working" || !this.binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + for (let attempt = 0;attempt < 3; attempt += 1) { + const existing = await this.#readPurge(); + if (existing !== null) { + this.#purged = existing; + return existing; + } + const head = await this.head(); + const receipt = createOhSpacePurgeReceiptV1({ binding: this.binding, priorHead: head, purgedAt }); + const receiptExists = "EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ? AND receipt_sha256 = ?)"; + const statements = [{ + sql: `INSERT INTO oh_authority_purges(space_id, + binding_sha256, prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ?) + AND EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ? AND binding_sha256 = ?)`, + args: [ + this.binding.spaceId, + this.binding.bindingSha256, + head.operationSha256, + head.sequence, + receipt.purgedAt, + receipt.receiptSha256, + canonicalJson(receipt), + this.binding.spaceId, + head.generation, + head.operationSha256, + this.binding.spaceId, + this.binding.bindingSha256 + ] + }]; + const guardedDelete = (table) => ({ + sql: `DELETE FROM ${table} WHERE space_id = ? AND ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] + }); + statements.push({ + sql: `DELETE FROM oh_authority_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) + AND ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] + }); + statements.push(guardedDelete("oh_authority_dependencies")); + statements.push(guardedDelete("oh_authority_records")); + statements.push(guardedDelete("oh_authority_operations")); + statements.push(guardedDelete("oh_authority_bindings")); + statements.push(guardedDelete("oh_authority_spaces")); + statements.push({ + sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR NOT ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] + }); + try { + await this.#client.batch(statements, "write"); + } catch { + const raced = await this.#readPurge(); + if (raced !== null) { + this.#purged = raced; + return raced; + } + continue; + } + const persisted = await this.#readPurge(); + if (persisted !== null) { + this.#purged = persisted; + return persisted; + } + } + throw new OhConflictError("The remote working space changed repeatedly while purging."); + } + async close() { + if (this.#closed) + return; + this.#closed = true; + if (this.#closeClient) + this.#client.close?.(); + } +} +async function createOhLibSqlStoreAuthorityV1(client, options = {}) { + const profile = parseOhStoreProfileV1(options.profile ?? OH_CANONICAL_STORE_PROFILE_V1); + if (profile === null) + throw new TypeError("Invalid libSQL store profile."); + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ + profile, + realmId: options.realmId ?? `realm:${spaceId}`, + spaceId, + v: 1 + }); + await verifyAuthoritySchema(client); + await initializeSpace(client, binding); + const authority = new OhLibSqlStoreV1(client, binding, options.closeClient ?? false); + const store = Object.freeze({ + binding, + changesSince: (from, changeOptions) => authority.changesSince(from, changeOptions), + close: () => authority.close(), + commit: (input) => authority.commit(input), + exportDependencyClosure: (input) => authority.exportDependencyClosure(input), + head: () => authority.head(), + snapshot: (snapshotOptions) => authority.snapshot(snapshotOptions), + verify: () => authority.verify() + }); + let purge = null; + const host = Object.freeze({ + binding, + purgeWorkingSpace: async (input) => { + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("This host handle is not bound to a purgeable working profile."); + } + if (purge !== null) + return purge; + purge = await authority.purgeWorkingSpace(input.purgedAt ?? canonicalNow()); + return purge; + } + }); + return Object.freeze({ host, store }); +} +export { + createOhLibSqlStoreAuthorityV1, + bootstrapOhLibSqlAuthorityV1 +}; diff --git a/dist/sdk.js b/dist/sdk.js index c3d45ee..d236237 100644 --- a/dist/sdk.js +++ b/dist/sdk.js @@ -1024,10 +1024,13 @@ function parseOhContractManifestV1(value) { class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1044,6 +1047,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/operation.ts @@ -1346,6 +1370,553 @@ async function searchOhV1(input) { import { mkdirSync } from "fs"; import { dirname } from "path"; +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} + // src/sqlite/driver.ts import { existsSync } from "fs"; import { Database } from "bun:sqlite"; @@ -1407,9 +1978,22 @@ function withImmediateTransaction(database, work) { throw error; } } +function withReadTransaction(database, work) { + database.exec("BEGIN"); + try { + const result = work(); + database.exec("COMMIT"); + return result; + } catch (error) { + try { + database.exec("ROLLBACK"); + } catch {} + throw error; + } +} // src/sqlite/migrations.ts -var OH_SQLITE_SCHEMA_VERSION = 1; +var OH_SQLITE_SCHEMA_VERSION = 2; var OH_SQLITE_MIGRATIONS = Object.freeze([ Object.freeze({ name: "0001_oh_core", @@ -1518,6 +2102,32 @@ CREATE VIRTUAL TABLE oh_search_fts USING fts5( CREATE INDEX oh_operations_space_sequence ON oh_operations(space_id, sequence); CREATE INDEX oh_records_space_kind ON oh_records(space_id, kind, record_key); CREATE INDEX oh_dependencies_dependency ON oh_dependencies(space_id, dependency_key); +` + }), + Object.freeze({ + name: "0002_store_realms", + version: 2, + sql: ` +CREATE TABLE oh_space_bindings ( + space_id TEXT PRIMARY KEY REFERENCES oh_spaces(space_id), + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL CHECK(length(profile_sha256) = 64), + binding_sha256 TEXT NOT NULL UNIQUE CHECK(length(binding_sha256) = 64), + binding_json TEXT NOT NULL CHECK(json_valid(binding_json)), + created_at TEXT NOT NULL +) STRICT; + +CREATE TABLE oh_space_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL CHECK(length(binding_sha256) = 64), + prior_operation_sha256 TEXT CHECK(prior_operation_sha256 IS NULL OR length(prior_operation_sha256) = 64), + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE CHECK(length(receipt_sha256) = 64), + receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)) +) STRICT; ` }) ]); @@ -1554,28 +2164,7 @@ function applyOhSqliteMigrations(database) { } // src/sqlite/store.ts -var EMPTY_RECORDS_SHA256 = canonicalSha256([]); - -class OhConflictError extends Error { - constructor(message) { - super(message); - this.name = "OhConflictError"; - } -} - -class OhIntegrityError extends Error { - constructor(message) { - super(message); - this.name = "OhIntegrityError"; - } -} - -class OhDependencyError extends Error { - constructor(message) { - super(message); - this.name = "OhDependencyError"; - } -} +var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -1663,6 +2252,12 @@ class OhSqliteStore { if (this.#closed) throw new Error("The Oh store is closed."); } + #assertOperationReplication() { + const binding = this.binding(); + if (binding !== null && !binding.profile.capabilities.operationReplication) { + throw new OhProfileError("This bound store profile forbids operation replication."); + } + } #registerContract() { const manifestJson = canonicalJson(OH_CONTRACT_MANIFEST_V1); this.database.query(`INSERT INTO oh_contracts(contract_id, contract_sha256, manifest_json, created_at) @@ -1674,13 +2269,61 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); + const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); + if (purged !== null) { + let value; + try { + value = JSON.parse(purged.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { + throw new OhIntegrityError("A stored purge receipt is invalid."); + } + throw new OhPurgedSpaceError(receipt); + } const now = canonicalNow(); this.database.query(`INSERT INTO oh_spaces( space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now); + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); return this.head(); } + bind(bindingValue) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId) { + throw new OhProfileError("The store binding does not identify this space."); + } + const bindingJson = canonicalJson(binding); + this.database.query(`INSERT INTO oh_space_bindings( + space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== bindingJson) { + throw new OhProfileError("The space is already bound to a different realm or profile."); + } + return binding; + } + binding() { + this.#assertOpen(); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null) + return null; + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json) { + throw new OhIntegrityError("A stored binding is invalid."); + } + return binding; + } head() { this.#assertOpen(); const row = this.database.query(`SELECT generation, graph_revision_sha256, @@ -1836,6 +2479,7 @@ class OhSqliteStore { } importOperation(value) { this.#assertOpen(); + this.#assertOperationReplication(); const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); @@ -1861,6 +2505,7 @@ class OhSqliteStore { } exportOperations(afterSequence = 0, limit = 1000) { this.#assertOpen(); + this.#assertOperationReplication(); if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); @@ -1872,6 +2517,143 @@ class OhSqliteStore { return operation; }); } + #headAt(reference) { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) + throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) + return emptyOhHeadV1(); + const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + if (row === null) + throw new OhConflictError("The requested head is not present in this space."); + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + snapshotAtHead(options = {}) { + this.#assertOpen(); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + return withReadTransaction(this.database, () => { + const current = this.head(); + const target = options.head === undefined ? current : this.#headAt(options.head); + if (target.sequence > current.sequence) + throw new OhConflictError("The requested head is ahead of this space."); + const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); + const operations = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + if (canonicalJson(value) !== row.operation_json) + throw new OhIntegrityError("A stored operation is not canonical JSON."); + const operation = parseOhOperationV1(value); + if (operation === null) + throw new OhIntegrityError("A stored operation is invalid."); + return operation; + }); + const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Operation replay does not reproduce the requested head."); + } + return snapshot; + }); + } + changesSince(fromValue, options = {}) { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) + throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit, 100, 1000); + return withReadTransaction(this.database, () => { + const current = this.head(); + const fromHead = this.#headAt(from); + const through = options.through === undefined ? current : this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); + } + const rows = this.database.query(`SELECT operation_json FROM oh_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? + ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); + const parsed = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; + }); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + const first = operations[0]; + if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { + throw new OhIntegrityError("The change feed does not extend its cursor."); + } + for (let index = 1;index < operations.length; index += 1) { + const prior = operations[index - 1]; + const operation = operations[index]; + if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The change feed contains a gap or fork."); + } + } + const last = operations.at(-1); + const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; + return { + from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, + operations, + through, + to, + v: 1 + }; + }); + } + exportDependencyClosure(input) { + const binding = parseOhStoreBindingV1(input.binding); + if (binding === null || binding.spaceId !== this.spaceId || canonicalJson(this.binding()) !== canonicalJson(binding)) { + throw new OhProfileError("Dependency closure export requires the exact persisted store binding."); + } + if (!binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This store profile does not permit dependency-closure export."); + } + const snapshot = this.snapshotAtHead({ + ...input.head === undefined ? {} : { head: input.head }, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords } + }); + return createOhDependencyClosureV1({ + binding, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }, + roots: input.roots, + snapshot + }); + } get(key) { this.#assertOpen(); const parsedKey = safeCode(key, 512); @@ -1977,7 +2759,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = storedCount <= 1000 ? this.exportOperations(0, 1000) : this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { + const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { let value; try { value = JSON.parse(row.operation_json); @@ -1991,6 +2773,8 @@ class OhSqliteStore { throw new OhIntegrityError("A stored operation is invalid."); return parsed; }); + if (operations.length !== storedCount) + throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); } #verifyOperations(operations) { @@ -2000,7 +2784,7 @@ class OhSqliteStore { generation: 0, graphRevisionSha256: null, operationSha256: null, - recordsSha256: EMPTY_RECORDS_SHA256, + recordsSha256: EMPTY_RECORDS_SHA2562, sequence: 0, v: 1 }; @@ -2083,6 +2867,35 @@ class OhSqliteStore { contract() { return { manifest: OH_CONTRACT_MANIFEST_V1, sqliteSchemaVersion: OH_SQLITE_SCHEMA_VERSION }; } + purgeWorkingSpace(bindingValue, purgedAt = canonicalNow()) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + return withImmediateTransaction(this.database, () => { + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== canonicalJson(binding)) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); + this.database.query(`INSERT INTO oh_space_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(this.spaceId, binding.bindingSha256, receipt.priorHead.operationSha256, receipt.priorHead.sequence, receipt.purgedAt, receipt.receiptSha256, canonicalJson(receipt)); + this.database.query("DELETE FROM oh_search_fts WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_search_documents WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_dependencies WHERE space_id = ?").run(this.spaceId); + this.database.query(`DELETE FROM oh_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_operations WHERE space_id = ?)`).run(this.spaceId); + this.database.query("DELETE FROM oh_sync_outbox WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_sync_state WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_records WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + return receipt; + }); + } close() { if (this.#closed) return; diff --git a/dist/sqlite/driver.d.ts b/dist/sqlite/driver.d.ts index c1b9671..abe3d20 100644 --- a/dist/sqlite/driver.d.ts +++ b/dist/sqlite/driver.d.ts @@ -2,4 +2,5 @@ import { Database } from "bun:sqlite"; export type OhSqliteDatabase = Database; export declare function openOhSqliteDatabase(path: string): OhSqliteDatabase; export declare function withImmediateTransaction(database: OhSqliteDatabase, work: () => T): T; +export declare function withReadTransaction(database: OhSqliteDatabase, work: () => T): T; //# sourceMappingURL=driver.d.ts.map \ No newline at end of file diff --git a/dist/sqlite/driver.d.ts.map b/dist/sqlite/driver.d.ts.map index 6d1c799..ff9a790 100644 --- a/dist/sqlite/driver.d.ts.map +++ b/dist/sqlite/driver.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/sqlite/driver.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAItC,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;AASxC,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAQnE;AAED,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAUxF"} \ No newline at end of file +{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/sqlite/driver.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAItC,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;AASxC,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAQnE;AAED,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAUxF;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAUnF"} \ No newline at end of file diff --git a/dist/sqlite/index.d.ts b/dist/sqlite/index.d.ts index 9a68a84..a2db9b7 100644 --- a/dist/sqlite/index.d.ts +++ b/dist/sqlite/index.d.ts @@ -1,4 +1,5 @@ export * from "./driver"; export * from "./migrations"; +export * from "./port"; export * from "./store"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/sqlite/index.d.ts.map b/dist/sqlite/index.d.ts.map index c8dc181..20425a8 100644 --- a/dist/sqlite/index.d.ts.map +++ b/dist/sqlite/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sqlite/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,SAAS,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sqlite/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,QAAQ,CAAC;AACvB,cAAc,SAAS,CAAC"} \ No newline at end of file diff --git a/dist/sqlite/index.js b/dist/sqlite/index.js index a03f774..e77f858 100644 --- a/dist/sqlite/index.js +++ b/dist/sqlite/index.js @@ -60,6 +60,19 @@ function withImmediateTransaction(database, work) { throw error; } } +function withReadTransaction(database, work) { + database.exec("BEGIN"); + try { + const result = work(); + database.exec("COMMIT"); + return result; + } catch (error) { + try { + database.exec("ROLLBACK"); + } catch {} + throw error; + } +} // src/canonical.ts import { createHash, randomBytes } from "crypto"; @@ -239,7 +252,7 @@ function sortUnique(values, key) { } // src/sqlite/migrations.ts -var OH_SQLITE_SCHEMA_VERSION = 1; +var OH_SQLITE_SCHEMA_VERSION = 2; var OH_SQLITE_MIGRATIONS = Object.freeze([ Object.freeze({ name: "0001_oh_core", @@ -348,6 +361,32 @@ CREATE VIRTUAL TABLE oh_search_fts USING fts5( CREATE INDEX oh_operations_space_sequence ON oh_operations(space_id, sequence); CREATE INDEX oh_records_space_kind ON oh_records(space_id, kind, record_key); CREATE INDEX oh_dependencies_dependency ON oh_dependencies(space_id, dependency_key); +` + }), + Object.freeze({ + name: "0002_store_realms", + version: 2, + sql: ` +CREATE TABLE oh_space_bindings ( + space_id TEXT PRIMARY KEY REFERENCES oh_spaces(space_id), + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL CHECK(length(profile_sha256) = 64), + binding_sha256 TEXT NOT NULL UNIQUE CHECK(length(binding_sha256) = 64), + binding_json TEXT NOT NULL CHECK(json_valid(binding_json)), + created_at TEXT NOT NULL +) STRICT; + +CREATE TABLE oh_space_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL CHECK(length(binding_sha256) = 64), + prior_operation_sha256 TEXT CHECK(prior_operation_sha256 IS NULL OR length(prior_operation_sha256) = 64), + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE CHECK(length(receipt_sha256) = 64), + receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)) +) STRICT; ` }) ]); @@ -382,10 +421,6 @@ function applyOhSqliteMigrations(database) { } } } -// src/sqlite/store.ts -import { mkdirSync } from "fs"; -import { dirname } from "path"; - // src/graph.ts var OH_GRAPH_FORMAT_VERSION_V1 = 1; var OH_GRAPH_LIMITS_V1 = Object.freeze({ @@ -1233,10 +1268,13 @@ function parseOhContractManifestV1(value) { class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1253,6 +1291,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/operation.ts @@ -1321,9 +1380,7 @@ function parseOhOperationV1(value) { return operationSha256 !== null && payload !== null && Buffer.byteLength(canonicalJson({ ...payload, operationSha256 }), "utf8") <= OH_OPERATION_MAX_BYTES_V1 && canonicalSha256(payload) === operationSha256 ? { ...payload, operationSha256 } : null; } -// src/sqlite/store.ts -var EMPTY_RECORDS_SHA256 = canonicalSha256([]); - +// src/store.ts class OhConflictError extends Error { constructor(message) { super(message); @@ -1344,6 +1401,536 @@ class OhDependencyError extends Error { this.name = "OhDependencyError"; } } + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} + +// src/sqlite/store.ts +import { mkdirSync } from "fs"; +import { dirname } from "path"; +var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -1431,6 +2018,12 @@ class OhSqliteStore { if (this.#closed) throw new Error("The Oh store is closed."); } + #assertOperationReplication() { + const binding = this.binding(); + if (binding !== null && !binding.profile.capabilities.operationReplication) { + throw new OhProfileError("This bound store profile forbids operation replication."); + } + } #registerContract() { const manifestJson = canonicalJson(OH_CONTRACT_MANIFEST_V1); this.database.query(`INSERT INTO oh_contracts(contract_id, contract_sha256, manifest_json, created_at) @@ -1442,13 +2035,61 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); + const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); + if (purged !== null) { + let value; + try { + value = JSON.parse(purged.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { + throw new OhIntegrityError("A stored purge receipt is invalid."); + } + throw new OhPurgedSpaceError(receipt); + } const now = canonicalNow(); this.database.query(`INSERT INTO oh_spaces( space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now); + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); return this.head(); } + bind(bindingValue) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId) { + throw new OhProfileError("The store binding does not identify this space."); + } + const bindingJson = canonicalJson(binding); + this.database.query(`INSERT INTO oh_space_bindings( + space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== bindingJson) { + throw new OhProfileError("The space is already bound to a different realm or profile."); + } + return binding; + } + binding() { + this.#assertOpen(); + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null) + return null; + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json) { + throw new OhIntegrityError("A stored binding is invalid."); + } + return binding; + } head() { this.#assertOpen(); const row = this.database.query(`SELECT generation, graph_revision_sha256, @@ -1604,6 +2245,7 @@ class OhSqliteStore { } importOperation(value) { this.#assertOpen(); + this.#assertOperationReplication(); const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); @@ -1629,6 +2271,7 @@ class OhSqliteStore { } exportOperations(afterSequence = 0, limit = 1000) { this.#assertOpen(); + this.#assertOperationReplication(); if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); @@ -1640,6 +2283,143 @@ class OhSqliteStore { return operation; }); } + #headAt(reference) { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) + throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) + return emptyOhHeadV1(); + const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + if (row === null) + throw new OhConflictError("The requested head is not present in this space."); + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + snapshotAtHead(options = {}) { + this.#assertOpen(); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + return withReadTransaction(this.database, () => { + const current = this.head(); + const target = options.head === undefined ? current : this.#headAt(options.head); + if (target.sequence > current.sequence) + throw new OhConflictError("The requested head is ahead of this space."); + const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); + const operations = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + if (canonicalJson(value) !== row.operation_json) + throw new OhIntegrityError("A stored operation is not canonical JSON."); + const operation = parseOhOperationV1(value); + if (operation === null) + throw new OhIntegrityError("A stored operation is invalid."); + return operation; + }); + const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Operation replay does not reproduce the requested head."); + } + return snapshot; + }); + } + changesSince(fromValue, options = {}) { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) + throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit, 100, 1000); + return withReadTransaction(this.database, () => { + const current = this.head(); + const fromHead = this.#headAt(from); + const through = options.through === undefined ? current : this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); + } + const rows = this.database.query(`SELECT operation_json FROM oh_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? + ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); + const parsed = rows.map((row) => { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; + }); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + const first = operations[0]; + if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { + throw new OhIntegrityError("The change feed does not extend its cursor."); + } + for (let index = 1;index < operations.length; index += 1) { + const prior = operations[index - 1]; + const operation = operations[index]; + if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The change feed contains a gap or fork."); + } + } + const last = operations.at(-1); + const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; + return { + from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, + operations, + through, + to, + v: 1 + }; + }); + } + exportDependencyClosure(input) { + const binding = parseOhStoreBindingV1(input.binding); + if (binding === null || binding.spaceId !== this.spaceId || canonicalJson(this.binding()) !== canonicalJson(binding)) { + throw new OhProfileError("Dependency closure export requires the exact persisted store binding."); + } + if (!binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This store profile does not permit dependency-closure export."); + } + const snapshot = this.snapshotAtHead({ + ...input.head === undefined ? {} : { head: input.head }, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords } + }); + return createOhDependencyClosureV1({ + binding, + ...input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }, + roots: input.roots, + snapshot + }); + } get(key) { this.#assertOpen(); const parsedKey = safeCode(key, 512); @@ -1745,7 +2525,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = storedCount <= 1000 ? this.exportOperations(0, 1000) : this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { + const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { let value; try { value = JSON.parse(row.operation_json); @@ -1759,6 +2539,8 @@ class OhSqliteStore { throw new OhIntegrityError("A stored operation is invalid."); return parsed; }); + if (operations.length !== storedCount) + throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); } #verifyOperations(operations) { @@ -1768,7 +2550,7 @@ class OhSqliteStore { generation: 0, graphRevisionSha256: null, operationSha256: null, - recordsSha256: EMPTY_RECORDS_SHA256, + recordsSha256: EMPTY_RECORDS_SHA2562, sequence: 0, v: 1 }; @@ -1851,6 +2633,35 @@ class OhSqliteStore { contract() { return { manifest: OH_CONTRACT_MANIFEST_V1, sqliteSchemaVersion: OH_SQLITE_SCHEMA_VERSION }; } + purgeWorkingSpace(bindingValue, purgedAt = canonicalNow()) { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + return withImmediateTransaction(this.database, () => { + const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + if (row === null || row.binding_json !== canonicalJson(binding)) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); + this.database.query(`INSERT INTO oh_space_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(this.spaceId, binding.bindingSha256, receipt.priorHead.operationSha256, receipt.priorHead.sequence, receipt.purgedAt, receipt.receiptSha256, canonicalJson(receipt)); + this.database.query("DELETE FROM oh_search_fts WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_search_documents WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_dependencies WHERE space_id = ?").run(this.spaceId); + this.database.query(`DELETE FROM oh_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_operations WHERE space_id = ?)`).run(this.spaceId); + this.database.query("DELETE FROM oh_sync_outbox WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_sync_state WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_records WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + return receipt; + }); + } close() { if (this.#closed) return; @@ -1858,11 +2669,91 @@ class OhSqliteStore { this.#closed = true; } } + +// src/sqlite/port.ts +class OhSqliteStorePortV1 { + binding; + #authority; + constructor(authority, binding) { + const persisted = authority.bind(binding); + if (canonicalJson(persisted) !== canonicalJson(binding)) { + throw new OhProfileError("The SQLite authority returned a different store binding."); + } + this.#authority = authority; + this.binding = persisted; + } + async head() { + return this.#authority.head(); + } + async snapshot(options = {}) { + return this.#authority.snapshotAtHead(options); + } + async changesSince(from, options = {}) { + return this.#authority.changesSince(from, options); + } + async commit(input) { + return this.#authority.commit(input); + } + async exportDependencyClosure(input) { + return this.#authority.exportDependencyClosure({ binding: this.binding, ...input }); + } + async verify() { + const verified = this.#authority.verifyReplay(); + return { + head: verified.head, + integrity: "verified", + operations: verified.operations, + records: verified.records, + v: 1 + }; + } + async close() { + this.#authority.close(); + } +} +function createOhSqliteStoreAuthorityV1(options = {}) { + const profile = parseOhStoreProfileV1(options.profile ?? OH_CANONICAL_STORE_PROFILE_V1); + if (profile === null) + throw new TypeError("Invalid SQLite store profile."); + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ + profile, + realmId: options.realmId ?? `realm:${spaceId}`, + spaceId, + v: 1 + }); + const authority = new OhSqliteStore({ + ...options.database === undefined ? {} : { database: options.database }, + ...options.path === undefined ? {} : { path: options.path }, + spaceId + }); + const store = new OhSqliteStorePortV1(authority, binding); + let purge = null; + const host = Object.freeze({ + binding, + purgeWorkingSpace: async (input) => { + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("This host handle is not bound to a purgeable working profile."); + } + if (purge !== null) + return purge; + purge = authority.purgeWorkingSpace(binding, input.purgedAt); + authority.close(); + return purge; + } + }); + return Object.freeze({ host, store }); +} export { + withReadTransaction, withImmediateTransaction, openOhSqliteDatabase, + createOhSqliteStoreAuthorityV1, applyOhSqliteMigrations, + OhSqliteStorePortV1, OhSqliteStore, + OhPurgedSpaceError, + OhProfileError, OhIntegrityError, OhDependencyError, OhConflictError, diff --git a/dist/sqlite/migrations.d.ts b/dist/sqlite/migrations.d.ts index 9c75854..659b320 100644 --- a/dist/sqlite/migrations.d.ts +++ b/dist/sqlite/migrations.d.ts @@ -1,5 +1,5 @@ import type { OhSqliteDatabase } from "./driver"; -export declare const OH_SQLITE_SCHEMA_VERSION: 1; +export declare const OH_SQLITE_SCHEMA_VERSION: 2; export type OhSqliteMigration = Readonly<{ name: string; sql: string; diff --git a/dist/sqlite/migrations.d.ts.map b/dist/sqlite/migrations.d.ts.map index b822e96..075fd85 100644 --- a/dist/sqlite/migrations.d.ts.map +++ b/dist/sqlite/migrations.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/sqlite/migrations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAEjD,eAAO,MAAM,wBAAwB,EAAG,CAAU,CAAC;AAEnD,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEzF,eAAO,MAAM,oBAAoB,EAAE,SAAS,iBAAiB,EA8G3D,CAAC;AAEH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAgCxE"} \ No newline at end of file +{"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/sqlite/migrations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAEjD,eAAO,MAAM,wBAAwB,EAAG,CAAU,CAAC;AAEnD,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEzF,eAAO,MAAM,oBAAoB,EAAE,SAAS,iBAAiB,EAwI3D,CAAC;AAEH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAgCxE"} \ No newline at end of file diff --git a/dist/sqlite/port.d.ts b/dist/sqlite/port.d.ts new file mode 100644 index 0000000..2a8afc8 --- /dev/null +++ b/dist/sqlite/port.d.ts @@ -0,0 +1,40 @@ +import { type OhChangesPageV1, type OhCommitInputV1, type OhDependencyClosureV1, type OhHeadRefV1, type OhHeadV1, type OhSnapshotV1, type OhStoreAuthorityV1, type OhStoreBindingV1, type OhStoreProfileV1, type OhStoreV1, type OhStoreVerificationV1 } from "../store"; +import type { OhOperationV1 } from "../operation"; +import type { OhSqliteDatabase } from "./driver"; +import { OhSqliteStore } from "./store"; +export type OhSqliteStoreAuthorityOptionsV1 = Readonly<{ + database?: OhSqliteDatabase; + path?: string; + profile?: OhStoreProfileV1; + realmId?: string; + spaceId?: string; +}>; +export declare class OhSqliteStorePortV1 implements OhStoreV1 { + #private; + readonly binding: OhStoreBindingV1; + constructor(authority: OhSqliteStore, binding: OhStoreBindingV1); + head(): Promise; + snapshot(options?: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }>): Promise; + changesSince(from: OhHeadRefV1, options?: Readonly<{ + limit?: number; + through?: OhHeadRefV1; + }>): Promise; + commit(input: OhCommitInputV1): Promise; + exportDependencyClosure(input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): Promise; + verify(): Promise; + close(): Promise; +} +/** + * Binds a Bun SQLite authority to the promise-based store port. Retain the + * returned `host` object in trusted control-plane code; pass only `store` to + * ordinary consumers. + */ +export declare function createOhSqliteStoreAuthorityV1(options?: OhSqliteStoreAuthorityOptionsV1): OhStoreAuthorityV1; +//# sourceMappingURL=port.d.ts.map \ No newline at end of file diff --git a/dist/sqlite/port.d.ts.map b/dist/sqlite/port.d.ts.map new file mode 100644 index 0000000..622f172 --- /dev/null +++ b/dist/sqlite/port.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../../src/sqlite/port.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,YAAY,EAEjB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EAErB,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC3B,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAExC,MAAM,MAAM,+BAA+B,GAAG,QAAQ,CAAC;IACrD,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH,qBAAa,mBAAoB,YAAW,SAAS;;IACnD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;gBAGvB,SAAS,EAAE,aAAa,EAAE,OAAO,EAAE,gBAAgB;IASzD,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC;IAIzB,QAAQ,CAAC,OAAO,GAAE,QAAQ,CAAC;QAC/B,IAAI,CAAC,EAAE,WAAW,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAIzB,YAAY,CAChB,IAAI,EAAE,WAAW,EACjB,OAAO,GAAE,QAAQ,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,CAAM,GAChE,OAAO,CAAC,eAAe,CAAC;IAIrB,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC;IAItD,uBAAuB,CAAC,KAAK,EAAE,QAAQ,CAAC;QAC5C,IAAI,CAAC,EAAE,WAAW,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;KAC1B,CAAC,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAI7B,MAAM,IAAI,OAAO,CAAC,qBAAqB,CAAC;IAMxC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,GAAE,+BAAoC,GAC5C,kBAAkB,CA0BpB"} \ No newline at end of file diff --git a/dist/sqlite/store.d.ts b/dist/sqlite/store.d.ts index e0416fb..3dc2da5 100644 --- a/dist/sqlite/store.d.ts +++ b/dist/sqlite/store.d.ts @@ -1,32 +1,11 @@ import { type Sha256Hex } from "../canonical"; import { OH_CONTRACT_MANIFEST_V1 } from "../contract"; -import { type KnowledgeGraphChangeV1, type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "../graph"; +import { type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "../graph"; import { type OhOperationV1 } from "../operation"; +import { OhConflictError, OhDependencyError, OhIntegrityError, OhProfileError, OhPurgedSpaceError, type OhChangesPageV1, type OhCommitInputV1, type OhDependencyClosureV1, type OhHeadRefV1, type OhHeadV1, type OhSnapshotV1, type OhSpacePurgeReceiptV1, type OhStoreBindingV1 } from "../store"; import { type OhSqliteDatabase } from "./driver"; -export declare class OhConflictError extends Error { - constructor(message: string); -} -export declare class OhIntegrityError extends Error { - constructor(message: string); -} -export declare class OhDependencyError extends Error { - constructor(message: string); -} -export type OhHeadV1 = Readonly<{ - generation: number; - graphRevisionSha256: Sha256Hex | null; - operationSha256: Sha256Hex | null; - recordsSha256: Sha256Hex; - sequence: number; - v: 1; -}>; -export type OhCommitInputV1 = Readonly<{ - actorId: string; - changes: readonly KnowledgeGraphChangeV1[]; - expectedHead: Pick; - instant?: string; - operationId: string; -}>; +export { OhConflictError, OhDependencyError, OhIntegrityError, OhProfileError, OhPurgedSpaceError, }; +export type { OhCommitInputV1, OhHeadV1 }; export type OhRecordListOptions = Readonly<{ kind?: KnowledgeGraphRecordKindV1; limit?: number; @@ -56,6 +35,8 @@ export declare class OhSqliteStore { spaceId?: string; }>); ensureSpace(): OhHeadV1; + bind(bindingValue: OhStoreBindingV1): OhStoreBindingV1; + binding(): OhStoreBindingV1 | null; head(): OhHeadV1; commit(input: OhCommitInputV1): OhOperationV1; importOperation(value: unknown): Readonly<{ @@ -63,6 +44,20 @@ export declare class OhSqliteStore { operation: OhOperationV1; }>; exportOperations(afterSequence?: number, limit?: number): readonly OhOperationV1[]; + snapshotAtHead(options?: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }>): OhSnapshotV1; + changesSince(fromValue: OhHeadRefV1, options?: Readonly<{ + limit?: number; + through?: OhHeadRefV1; + }>): OhChangesPageV1; + exportDependencyClosure(input: Readonly<{ + binding: OhStoreBindingV1; + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): OhDependencyClosureV1; get(key: string): KnowledgeGraphRecordV1 | null; list(options?: OhRecordListOptions): readonly KnowledgeGraphRecordV1[]; snapshotRecords(maximum?: number): readonly KnowledgeGraphRecordV1[]; @@ -83,6 +78,8 @@ export declare class OhSqliteStore { manifest: typeof OH_CONTRACT_MANIFEST_V1; sqliteSchemaVersion: number; }>; + /** Host control-plane primitive. Do not expose this method through agent tools. */ + purgeWorkingSpace(bindingValue: OhStoreBindingV1, purgedAt?: string): OhSpacePurgeReceiptV1; close(): void; } //# sourceMappingURL=store.d.ts.map \ No newline at end of file diff --git a/dist/sqlite/store.d.ts.map b/dist/sqlite/store.d.ts.map index 85bedab..48b59d7 100644 --- a/dist/sqlite/store.d.ts.map +++ b/dist/sqlite/store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/store.d.ts b/dist/store.d.ts new file mode 100644 index 0000000..af5b4ea --- /dev/null +++ b/dist/store.d.ts @@ -0,0 +1,221 @@ +import { type Sha256Hex } from "./canonical"; +import { type OhRecordCodecRegistry } from "./contract"; +import { type KnowledgeGraphChangeV1, type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "./graph"; +import { type OhOperationV1 } from "./operation"; +export declare class OhConflictError extends Error { + constructor(message: string); +} +export declare class OhIntegrityError extends Error { + constructor(message: string); +} +export declare class OhDependencyError extends Error { + constructor(message: string); +} +export declare class OhProfileError extends Error { + constructor(message: string); +} +export type OhHeadV1 = Readonly<{ + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordsSha256: Sha256Hex; + sequence: number; + v: 1; +}>; +export type OhHeadRefV1 = Pick; +export type OhCommitInputV1 = Readonly<{ + actorId: string; + changes: readonly KnowledgeGraphChangeV1[]; + expectedHead: Pick; + instant?: string; + operationId: string; +}>; +export type OhSnapshotV1 = Readonly<{ + head: OhHeadV1; + records: readonly KnowledgeGraphRecordV1[]; + v: 1; +}>; +export type OhChangesPageV1 = Readonly<{ + from: OhHeadRefV1; + hasMore: boolean; + operations: readonly OhOperationV1[]; + through: OhHeadV1; + to: OhHeadRefV1; + v: 1; +}>; +export type OhStoreVerificationV1 = Readonly<{ + head: OhHeadV1; + integrity: "verified"; + operations: number; + records: number; + v: 1; +}>; +export type OhStoreCapabilitiesV1 = Readonly<{ + changesSince: true; + dependencyClosureExport: true; + exactSnapshots: true; + operationReplication: boolean; + semanticBundleCommit: true; + v: 1; + wholeSpacePurge: boolean; +}>; +export type OhStoreProfileV1 = Readonly<{ + applicationProfileSha256: Sha256Hex | null; + capabilities: OhStoreCapabilitiesV1; + profileId: string; + profileKind: "canonical" | "working"; + profileSha256: Sha256Hex; + v: 1; +}>; +export type OhStoreBindingV1 = Readonly<{ + bindingSha256: Sha256Hex; + contractSha256: Sha256Hex; + profile: OhStoreProfileV1; + realmId: string; + spaceId: string; + v: 1; +}>; +export declare const OH_CANONICAL_STORE_PROFILE_V1: Readonly<{ + applicationProfileSha256: Sha256Hex | null; + capabilities: OhStoreCapabilitiesV1; + profileId: string; + profileKind: "canonical" | "working"; + profileSha256: Sha256Hex; + v: 1; +}>; +export declare const OH_WORKING_STORE_PROFILE_V1: Readonly<{ + applicationProfileSha256: Sha256Hex | null; + capabilities: OhStoreCapabilitiesV1; + profileId: string; + profileKind: "canonical" | "working"; + profileSha256: Sha256Hex; + v: 1; +}>; +export type OhDependencyClosureV1 = Readonly<{ + binding: OhStoreBindingV1; + closureSha256: Sha256Hex; + head: OhHeadV1; + records: readonly KnowledgeGraphRecordV1[]; + roots: readonly string[]; + v: 1; +}>; +export declare const OH_DEPENDENCY_CLOSURE_LIMITS_V1: Readonly<{ + bytes: number; + records: 8192; + roots: 1024; +}>; +export type OhSpacePurgeReceiptV1 = Readonly<{ + bindingSha256: Sha256Hex; + priorHead: OhHeadV1; + purgedAt: string; + receiptSha256: Sha256Hex; + spaceId: string; + v: 1; +}>; +export declare class OhPurgedSpaceError extends Error { + readonly receipt: OhSpacePurgeReceiptV1; + constructor(receipt: OhSpacePurgeReceiptV1); +} +export interface OhStoreV1 { + readonly binding: OhStoreBindingV1; + changesSince(from: OhHeadRefV1, options?: Readonly<{ + limit?: number; + through?: OhHeadRefV1; + }>): Promise; + close(): Promise; + commit(input: OhCommitInputV1): Promise; + exportDependencyClosure(input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): Promise; + head(): Promise; + snapshot(options?: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }>): Promise; + verify(): Promise; +} +/** Kept separate so an agent-facing store object never carries deletion authority. */ +export interface OhStoreHostControlV1 { + readonly binding: OhStoreBindingV1; + purgeWorkingSpace(input: Readonly<{ + purgedAt?: string; + }>): Promise; +} +export type OhStoreAuthorityV1 = Readonly<{ + host: OhStoreHostControlV1; + store: OhStoreV1; +}>; +export declare function emptyOhHeadV1(): OhHeadV1; +export declare function parseOhHeadV1(value: unknown): OhHeadV1 | null; +export declare function parseOhHeadRefV1(value: unknown): OhHeadRefV1 | null; +type OhStoreProfileInputV1 = Omit; +export declare function createOhStoreProfileV1(input: OhStoreProfileInputV1): OhStoreProfileV1; +export declare function parseOhStoreProfileV1(value: unknown): OhStoreProfileV1 | null; +export declare function createOhStoreBindingV1(input: Readonly<{ + profile: OhStoreProfileV1; + realmId: string; + spaceId: string; + v: 1; +}>): OhStoreBindingV1; +export declare function parseOhStoreBindingV1(value: unknown): OhStoreBindingV1 | null; +export declare function replayOhOperationsV1(spaceId: string, values: readonly OhOperationV1[], maximumRecords?: number): OhSnapshotV1; +export declare function transitionOhSnapshotV1(input: Readonly<{ + actorId: string; + changes: readonly KnowledgeGraphChangeV1[]; + instant: string; + operationId: string; + snapshot: OhSnapshotV1; + spaceId: string; +}>): Readonly<{ + operation: OhOperationV1; + snapshot: OhSnapshotV1; +}>; +export declare function createOhDependencyClosureV1(input: Readonly<{ + binding: OhStoreBindingV1; + maximumRecords?: number; + roots: readonly string[]; + snapshot: OhSnapshotV1; +}>): OhDependencyClosureV1; +export declare function parseOhDependencyClosureV1(value: unknown): OhDependencyClosureV1 | null; +export declare function verifyOhDependencyClosureV1(value: unknown): Readonly<{ + closure: OhDependencyClosureV1; + ok: true; +}> | Readonly<{ + ok: false; + reason: "invalid-closure"; +}>; +export declare function createOhSpacePurgeReceiptV1(input: Readonly<{ + binding: OhStoreBindingV1; + priorHead: OhHeadV1; + purgedAt: string; +}>): OhSpacePurgeReceiptV1; +export declare function parseOhSpacePurgeReceiptV1(value: unknown): OhSpacePurgeReceiptV1 | null; +export type OhSemanticBundleV1 = Readonly<{ + actorId: string; + expectedHead: Pick; + instant: string | null; + operationId: string; + puts: readonly Readonly<{ + dependencies: readonly string[]; + key: string; + kind: KnowledgeGraphRecordKindV1; + v: 1; + value: unknown; + }>[]; + tombstones: readonly Readonly<{ + key: string; + priorSha256: Sha256Hex; + v: 1; + }>[]; + v: 1; +}>; +/** A strict model-facing ingress: every put must have a registered codec. */ +export declare class OhSemanticBundleIngressV1 { + #private; + constructor(store: OhStoreV1, codecs: OhRecordCodecRegistry); + commit(value: unknown): Promise; +} +export {}; +//# sourceMappingURL=store.d.ts.map \ No newline at end of file diff --git a/dist/store.d.ts.map b/dist/store.d.ts.map new file mode 100644 index 0000000..acf9b38 --- /dev/null +++ b/dist/store.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AACrB,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAC;AACpB,OAAO,EAQL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC5B,MAAM,SAAS,CAAC;AACjB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AAErB,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAC5B;AAED,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAC5B;AAED,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,OAAO,EAAE,MAAM;CAC5B;AAED,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAC5B;AAED,MAAM,MAAM,QAAQ,GAAG,QAAQ,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB,EAAE,SAAS,GAAG,IAAI,CAAC;IACtC,eAAe,EAAE,SAAS,GAAG,IAAI,CAAC;IAClC,aAAa,EAAE,SAAS,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,EAAE,iBAAiB,GAAG,UAAU,CAAC,CAAC;AAEzE,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC3C,YAAY,EAAE,IAAI,CAAC,QAAQ,EAAE,YAAY,GAAG,iBAAiB,CAAC,CAAC;IAC/D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC3C,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,SAAS,aAAa,EAAE,CAAC;IACrC,OAAO,EAAE,QAAQ,CAAC;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,UAAU,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,YAAY,EAAE,IAAI,CAAC;IACnB,uBAAuB,EAAE,IAAI,CAAC;IAC9B,cAAc,EAAE,IAAI,CAAC;IACrB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,oBAAoB,EAAE,IAAI,CAAC;IAC3B,CAAC,EAAE,CAAC,CAAC;IACL,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,wBAAwB,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3C,YAAY,EAAE,qBAAqB,CAAC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,WAAW,GAAG,SAAS,CAAC;IACrC,aAAa,EAAE,SAAS,CAAC;IACzB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,aAAa,EAAE,SAAS,CAAC;IACzB,cAAc,EAAE,SAAS,CAAC;IAC1B,OAAO,EAAE,gBAAgB,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,eAAO,MAAM,6BAA6B;8BAjBd,SAAS,GAAG,IAAI;kBAC5B,qBAAqB;eACxB,MAAM;iBACJ,WAAW,GAAG,SAAS;mBACrB,SAAS;OACrB,CAAC;EA0BJ,CAAC;AAEH,eAAO,MAAM,2BAA2B;8BAjCZ,SAAS,GAAG,IAAI;kBAC5B,qBAAqB;eACxB,MAAM;iBACJ,WAAW,GAAG,SAAS;mBACrB,SAAS;OACrB,CAAC;EA0CJ,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,SAAS,CAAC;IACzB,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC3C,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,eAAO,MAAM,+BAA+B;;;;EAI1C,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,aAAa,EAAE,SAAS,CAAC;IACzB,SAAS,EAAE,QAAQ,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,SAAS,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,OAAO,EAAE,qBAAqB,CAAC;gBAE5B,OAAO,EAAE,qBAAqB;CAK3C;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,YAAY,CACV,IAAI,EAAE,WAAW,EACjB,OAAO,CAAC,EAAE,QAAQ,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,CAAC,GAC5D,OAAO,CAAC,eAAe,CAAC,CAAC;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACvD,uBAAuB,CAAC,KAAK,EAAE,QAAQ,CAAC;QACtC,IAAI,CAAC,EAAE,WAAW,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;KAC1B,CAAC,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACpC,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC;QAC1B,IAAI,CAAC,EAAE,WAAW,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAC3B,MAAM,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;CAC1C;AAED,sFAAsF;AACtF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,iBAAiB,CAAC,KAAK,EAAE,QAAQ,CAAC;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;CAC3F;AAED,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC;IACxC,IAAI,EAAE,oBAAoB,CAAC;IAC3B,KAAK,EAAE,SAAS,CAAC;CAClB,CAAC,CAAC;AAIH,wBAAgB,aAAa,IAAI,QAAQ,CASxC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAoB7D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAYnE;AAcD,KAAK,qBAAqB,GAAG,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC;AAErE,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,qBAAqB,GAAG,gBAAgB,CAsBrF;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAQ7E;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,QAAQ,CAAC;IACrD,OAAO,EAAE,gBAAgB,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,GAAG,gBAAgB,CAUpB;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CAY7E;AAcD,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,SAAS,aAAa,EAAE,EAChC,cAAc,GAAE,MAA8C,GAC7D,YAAY,CAyCd;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,QAAQ,CAAC;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,YAAY,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC,GAAG,QAAQ,CAAC;IAAE,SAAS,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAC,CAsDlE;AAkCD,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC1D,OAAO,EAAE,gBAAgB,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,QAAQ,EAAE,YAAY,CAAC;CACxB,CAAC,GAAG,qBAAqB,CAwBzB;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAyBvF;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GACtD,QAAQ,CAAC;IAAE,OAAO,EAAE,qBAAqB,CAAC;IAAC,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,GACtD,QAAQ,CAAC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAGrD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC1D,OAAO,EAAE,gBAAgB,CAAC;IAC1B,SAAS,EAAE,QAAQ,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,GAAG,qBAAqB,CAYzB;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAavF;AAED,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,IAAI,CAAC,QAAQ,EAAE,YAAY,GAAG,iBAAiB,CAAC,CAAC;IAC/D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,SAAS,QAAQ,CAAC;QACtB,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;QAChC,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,0BAA0B,CAAC;QACjC,CAAC,EAAE,CAAC,CAAC;QACL,KAAK,EAAE,OAAO,CAAC;KAChB,CAAC,EAAE,CAAC;IACL,UAAU,EAAE,SAAS,QAAQ,CAAC;QAC5B,GAAG,EAAE,MAAM,CAAC;QACZ,WAAW,EAAE,SAAS,CAAC;QACvB,CAAC,EAAE,CAAC,CAAC;KACN,CAAC,EAAE,CAAC;IACL,CAAC,EAAE,CAAC,CAAC;CACN,CAAC,CAAC;AAEH,6EAA6E;AAC7E,qBAAa,yBAAyB;;gBAIxB,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,qBAAqB;IAKrD,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC;CA6CrD"} \ No newline at end of file diff --git a/dist/store.js b/dist/store.js new file mode 100644 index 0000000..197519f --- /dev/null +++ b/dist/store.js @@ -0,0 +1,957 @@ +// src/canonical.ts +import { createHash, randomBytes } from "node:crypto"; + +class OhValidationError extends Error { + code; + path; + constructor(code, path, message) { + super(`${path}: ${message}`); + this.name = "OhValidationError"; + this.code = code; + this.path = path; + } +} +function isPlainRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasExactKeys(value, keys) { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function assertUnicodeScalarString(value, path) { + 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)) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + index += 1; + } else if (code >= 56320 && code <= 57343) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + } +} +function encodeCanonical(value, path, ancestors) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + assertUnicodeScalarString(value, path); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new OhValidationError("non-json-number", path, "must be finite"); + } + if (Object.is(value, -0)) { + throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null) { + throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`); + } + if (ancestors.has(value)) { + throw new OhValidationError("cycle", path, "contains a cycle"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes"); + } + encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors)); + } + const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)); + if (extraKeys.length > 0) { + throw new OhValidationError("non-json-property", path, "array has non-index properties"); + } + return `[${encoded.join(",")}]`; + } + if (!isPlainRecord(value)) { + throw new OhValidationError("non-plain-object", path, "must be a plain object"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new OhValidationError("non-json-property", path, "object has a symbol property"); + } + const keys = ownKeys; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property"); + } + } + keys.sort(); + const entries = keys.map((key) => { + assertUnicodeScalarString(key, `${path}.`); + return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`; + }); + return `{${entries.join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function canonicalJson(value) { + return encodeCanonical(value, "$", new Set); +} +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} +function canonicalSha256(value) { + return sha256Hex(canonicalJson(value)); +} +function parseSha256Hex(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +} +function parseCanonicalInstantV1(value) { + if (typeof value !== "string" || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null; +} +function canonicalNow() { + return new Date().toISOString(); +} +function safeCode(value, maximumLength = 128) { + return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function orderedUnique(values, key) { + return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); +} +function sortUnique(values, key) { + const sorted = [...values].sort((left, right) => { + const leftKey = key(left); + const rightKey = key(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + if (!orderedUnique(sorted, key)) { + throw new OhValidationError("duplicate", "$", "contains duplicate canonical values"); + } + return sorted; +} + +// src/graph.ts +var OH_GRAPH_FORMAT_VERSION_V1 = 1; +var OH_GRAPH_LIMITS_V1 = Object.freeze({ + changesPerOperation: 8192, + dependenciesPerRecord: 4096, + recordBytes: 1024 * 1024, + recordsPerSnapshot: 65536 +}); +var OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1 = [ + "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 recordKey(value) { + return typeof value === "string" && value.length <= 512 && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function createKnowledgeGraphRecordV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"]) || input.v !== 1 || !Array.isArray(input.dependencies)) + throw new TypeError("Invalid graph record input."); + const key = recordKey(input.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind); + if (key === null || kind === undefined || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) + throw new TypeError("Invalid graph record identity."); + const dependencies = input.dependencies.map(recordKey); + if (dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) { + throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive."); + } + const valueJson = canonicalJson(input.value); + if (Buffer.byteLength(valueJson, "utf8") > OH_GRAPH_LIMITS_V1.recordBytes) { + throw new RangeError("Graph record value exceeds its canonical byte limit."); + } + const payload = { dependencies, key, kind, v: 1, value: input.value }; + return { ...payload, recordSha256: canonicalSha256(payload) }; +} +function parseKnowledgeGraphRecordV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) + return null; + const recordSha256 = parseSha256Hex(value.recordSha256); + const { recordSha256: _digest, ...input } = value; + try { + const created = createKnowledgeGraphRecordV1(input); + return recordSha256 !== null && created.recordSha256 === recordSha256 ? { ...created, recordSha256 } : null; + } catch { + return null; + } +} +function knowledgeGraphRecordRefV1(record) { + return { + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + sha256: record.recordSha256, + v: 1 + }; +} +function changeKey(change) { + return change.kind === "put" ? change.record.key : change.key; +} +function canonicalKnowledgeGraphChangesV1(changes) { + const normalized = []; + for (const change of changes) { + if (!isPlainRecord(change) || change.v !== 1) + throw new TypeError("Invalid graph change."); + if (change.kind === "put") { + const record = parseKnowledgeGraphRecordV1(change.record); + if (record === null) + throw new TypeError("Invalid graph record in change."); + normalized.push({ kind: "put", record, v: 1 }); + } else if (change.kind === "tombstone") { + const key = recordKey(change.key); + const priorSha256 = parseSha256Hex(change.priorSha256); + if (key === null || priorSha256 === null) + throw new TypeError("Invalid graph tombstone."); + normalized.push({ key, kind: "tombstone", priorSha256, v: 1 }); + } else + throw new TypeError("Unknown graph change kind."); + } + return sortUnique(normalized, changeKey); +} +function graphRevisionSha256V1(input) { + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + const operationId = safeCode(input.operationId); + const parentGraphRevisionSha256 = input.parentGraphRevisionSha256 === null ? null : parseSha256Hex(input.parentGraphRevisionSha256); + const recordsSha256 = parseSha256Hex(input.recordsSha256); + const revision = Number.isSafeInteger(input.revision) && input.revision > 0 ? input.revision : null; + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation || operationId === null || recordsSha256 === null || revision === null || input.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || revision === 1 !== (parentGraphRevisionSha256 === null)) { + throw new TypeError("Invalid graph revision digest input."); + } + return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); +} + +// src/ontology.ts +var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; +var OH_CONTRACT_ID_V1 = "oh.ontology.v1"; +var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ + dimensions: 64, + listValues: 256, + qualifiers: 128, + statementBytes: 256 * 1024, + textBytes: 64 * 1024 +}); + +// src/schema.ts +var OH_SCHEMA_FORMAT_VERSION_V1 = 1; + +// src/contract.ts +var manifestPayload = Object.freeze({ + contractId: OH_CONTRACT_ID_V1, + graphFormatVersion: OH_GRAPH_FORMAT_VERSION_V1, + ontologyVersion: OH_ONTOLOGY_VERSION_V1, + recordKinds: OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + schemaFormatVersion: OH_SCHEMA_FORMAT_VERSION_V1, + v: 1 +}); +var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ + ...manifestPayload, + contractSha256: canonicalSha256(manifestPayload) +}); +class OhRecordCodecRegistry { + #codecs = new Map; + #sealed = false; + register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); + if (this.#codecs.has(codec.kind)) + throw new TypeError(`A codec is already registered for ${codec.kind}.`); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); + return this; + } + parse(kind, value) { + const codec = this.#codecs.get(kind); + if (codec !== undefined) + return codec.parse(value); + try { + canonicalJson(value); + return value; + } catch { + return null; + } + } + has(kind) { + return this.#codecs.has(kind); + } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } +} + +// src/operation.ts +var OH_OPERATION_MAX_BYTES_V1 = 64 * 1024 * 1024; +function parsePayload(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "changes", + "contractId", + "graphRevisionSha256", + "instant", + "operationId", + "parentOperationSha256", + "recordsSha256", + "sequence", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractId !== OH_CONTRACT_ID_V1 || !Array.isArray(value.changes)) + return null; + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const spaceId = safeCode(value.spaceId); + const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); + const parentOperationSha256 = value.parentOperationSha256 === null ? null : parseSha256Hex(value.parentOperationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const instant = parseCanonicalInstantV1(value.instant); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence > 0 ? value.sequence : null; + let changes; + try { + changes = canonicalKnowledgeGraphChangesV1(value.changes); + } catch { + return null; + } + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) + return null; + return actorId !== null && operationId !== null && spaceId !== null && graphRevisionSha256 !== null && recordsSha256 !== null && instant !== null && sequence !== null && (value.parentOperationSha256 === null || parentOperationSha256 !== null) && sequence === 1 === (parentOperationSha256 === null) ? { + actorId, + changes, + contractId: OH_CONTRACT_ID_V1, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256, + recordsSha256, + sequence, + spaceId, + v: 1 + } : null; +} +function createOhOperationV1(input) { + const payload = parsePayload(input); + if (payload === null) + throw new TypeError("Invalid Oh operation payload."); + const operation = { ...payload, operationSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(operation), "utf8") > OH_OPERATION_MAX_BYTES_V1) { + throw new RangeError("Oh operation exceeds its canonical byte limit."); + } + return operation; +} +function parseOhOperationV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "operationSha256")) + return null; + const operationSha256 = parseSha256Hex(value.operationSha256); + const { operationSha256: _digest, ...input } = value; + const payload = parsePayload(input); + return operationSha256 !== null && payload !== null && Buffer.byteLength(canonicalJson({ ...payload, operationSha256 }), "utf8") <= OH_OPERATION_MAX_BYTES_V1 && canonicalSha256(payload) === operationSha256 ? { ...payload, operationSha256 } : null; +} + +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords) { + const selected = new Map; + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} +export { + verifyOhDependencyClosureV1, + transitionOhSnapshotV1, + replayOhOperationsV1, + parseOhStoreProfileV1, + parseOhStoreBindingV1, + parseOhSpacePurgeReceiptV1, + parseOhHeadV1, + parseOhHeadRefV1, + parseOhDependencyClosureV1, + emptyOhHeadV1, + createOhStoreProfileV1, + createOhStoreBindingV1, + createOhSpacePurgeReceiptV1, + createOhDependencyClosureV1, + OhSemanticBundleIngressV1, + OhPurgedSpaceError, + OhProfileError, + OhIntegrityError, + OhDependencyError, + OhConflictError, + OH_WORKING_STORE_PROFILE_V1, + OH_DEPENDENCY_CLOSURE_LIMITS_V1, + OH_CANONICAL_STORE_PROFILE_V1 +}; diff --git a/dist/sync.js b/dist/sync.js index 5cf3394..a02e5fa 100644 --- a/dist/sync.js +++ b/dist/sync.js @@ -1024,10 +1024,13 @@ function parseOhContractManifestV1(value) { class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1044,6 +1047,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/operation.ts diff --git a/package.json b/package.json index 7f6c401..cd6e253 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "sideEffects": false, "packageManager": "bun@1.3.14", "engines": { - "bun": ">=1.3.14" + "bun": ">=1.3.14", + "node": ">=24" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -39,6 +40,14 @@ "types": "./dist/sdk.d.ts", "import": "./dist/sdk.js" }, + "./store": { + "types": "./dist/store.d.ts", + "import": "./dist/store.js" + }, + "./libsql": { + "types": "./dist/libsql.d.ts", + "import": "./dist/libsql.js" + }, "./sqlite": { "types": "./dist/sqlite/index.d.ts", "import": "./dist/sqlite/index.js" @@ -73,10 +82,12 @@ "access": "public" }, "scripts": { - "build": "bun run build:js && bun run build:types", - "build:js": "bun build ./src/index.ts ./src/sdk.ts ./src/sqlite/index.ts ./src/sync.ts ./src/semantic.ts ./src/projection-public.ts ./src/projection-suss.ts ./src/cli.ts --outdir ./dist --target bun --format esm --external bun:sqlite --external @suss/datalog", + "build": "bun run build:js && bun run build:portable && bun run build:types", + "build:js": "bun build ./src/index.ts ./src/sdk.ts ./src/sqlite/index.ts ./src/sync.ts ./src/semantic.ts ./src/cli.ts --outdir ./dist --target bun --format esm --external bun:sqlite", + "build:portable": "bun build ./src/store.ts ./src/libsql.ts ./src/projection-public.ts ./src/projection-suss.ts --outdir ./dist --target node --format esm --external @suss/datalog", "build:types": "tsc -p tsconfig.build.json", - "check": "bun run typecheck && bun run test && bun run build && bun run test:node-projection", + "check": "bun run typecheck && bun run test && bun run build && bun run test:node && bun run test:node-projection", + "test:node": "node ./tests/node-portable.mjs", "test": "bun test ./src ./tests ./site/tests/source.test.ts", "test:node-projection": "node --test ./scripts/projection-node.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/site/app/spec/page.tsx b/site/app/spec/page.tsx index 2ba0403..8d493d5 100644 --- a/site/app/spec/page.tsx +++ b/site/app/spec/page.tsx @@ -98,7 +98,7 @@ export default function Specification() {

Ontology{contract.ontologyVersion}
Graph format{contract.graphFormatVersion}
Schema format{contract.schemaFormatVersion}
-
SQLite schema1
+
SQLite schema2
Sync protocoloh.sync.v1
HashSHA-256
@@ -182,8 +182,8 @@ export default function Specification() {

The transport port is HTTP-friendly and compatible with a libSQL/Turso implementation, while the local store remains the - source of truth. No cloud credential or endpoint appears in the - core package. + source of truth. A separate promise-based adapter can instead + use libSQL as the direct authority without importing Bun SQLite.

diff --git a/site/public/spec/README.md b/site/public/spec/README.md index 9e4b1b5..85b3fbf 100644 --- a/site/public/spec/README.md +++ b/site/public/spec/README.md @@ -2,8 +2,8 @@ This directory is the versioned public contract for Oh. It defines the canonical bytes, ontology identities, graph envelopes, schema revisions, -SQLite state, operation sync, and local embedding profile that independent -implementations need to interoperate. +SQLite and direct libSQL authority, operation sync, store profiles, and local +embedding profile that independent implementations need to interoperate. [`manifest.json`](manifest.json) is the discovery document. V1 is current and binds these versions: @@ -14,7 +14,7 @@ binds these versions: | Contract ID | `oh.ontology.v1` | | Graph format | `1` | | Schema format | `1` | -| SQLite schema | `1` | +| SQLite schema | `2` | | Sync protocol | `oh.sync.v1` | | Embedding profile | `1` | @@ -25,6 +25,7 @@ binds these versions: - [Schema evolution](v1/schema-evolution.md) - [Graph and operations](v1/graph.md) - [SQLite storage](v1/storage.md) +- [Store ports, profiles, and direct libSQL authority](v1/store.md) - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) - [Derived projections](v1/projection.md) diff --git a/site/public/spec/v1/migration.md b/site/public/spec/v1/migration.md index 1e7d701..05a0ea4 100644 --- a/site/public/spec/v1/migration.md +++ b/site/public/spec/v1/migration.md @@ -77,3 +77,16 @@ rollback depend on reversing a content or identifier rewrite. SQLite schema migrations are separate from space transfer. The store records each applied migration name and SQL digest in `oh_migrations` and refuses to open when an applied version has different migration bytes. + +SQLite schema version 2 appends `0002_store_realms` without changing the +released `0001_oh_core` SQL. Existing spaces remain unbound after upgrade. A +host may bind one through the promise-based store authority; once persisted, +the exact realm and profile bytes cannot be replaced. A purged working space +cannot be used as a migration source or recreated under the same identifier. + +The direct libSQL authority has its own `oh_authority_` schema digest. It emits +the same V1 record and operation bytes, but it is not a destination for the +offline CLI import procedure above. Applications moving authority between +adapters MUST prove an exact complete operation chain and matching head through +a separately reviewed migration workflow. A dependency-closure capsule is a +selective content export for adoption, not proof of full authority migration. diff --git a/site/public/spec/v1/storage.md b/site/public/spec/v1/storage.md index 085d4c4..8cc36e5 100644 --- a/site/public/spec/v1/storage.md +++ b/site/public/spec/v1/storage.md @@ -1,6 +1,6 @@ # SQLite storage V1 -SQLite schema version `1` is the local authority for an Oh space. The default +SQLite schema version `2` is the local authority for an Oh space. The default CLI database is `.oh/oh.sqlite`; callers may select another path or use an in-memory database. @@ -34,10 +34,37 @@ rolls back the transaction and preserves the original error. | `oh_sync_state` | Last settled state for a named remote. | | `oh_search_documents` | Derived keyword text bound to a record digest. | | `oh_search_fts` | Derived FTS5 index. | +| `oh_space_bindings` | Host-selected realm, lifecycle profile, capabilities, and application-profile digest for a supported store port. | +| `oh_space_purges` | Minimal receipt that permanently reserves the identifier of a purged working space. | -The first migration is named `0001_oh_core`. An implementation MUST store and -check the exact SHA-256 digest of applied migration SQL. It MUST refuse to run -when the same migration version or name has different bytes. +The first migration is named `0001_oh_core`; its released bytes remain +unchanged. Schema version 2 adds `0002_store_realms`. An implementation MUST +store and check the exact SHA-256 digest of applied migration SQL. It MUST +refuse to run when the same migration version or name has different bytes. + +## Realm profiles and purge + +A promise-based store port MAY bind one space to one host-selected realm and +profile. The binding includes the exact Oh contract digest, an optional +application-profile digest, and declared capabilities. A supported runtime +MUST reject a later attempt to open the same space under different binding +bytes. A working profile disables operation replication and enables only +host-controlled whole-space purge. A canonical profile cannot be purged by +that API. + +Purge deletes the space head, complete operation history, current records, +dependency and operation materializations, sync state, and derived keyword +rows in one immediate transaction. It leaves only a content-free receipt with +the prior head, binding digest, purge instant, and receipt digest. The purged +space identifier cannot be reopened in the same database. A host that deletes +an entire database file MUST retain any required deletion evidence in its own +control plane. + +Realm and profile binding is additive store control metadata. V1 operation +digest preimages do not contain the binding. It therefore protects supported +opens and operations but is not a portable cryptographic claim about a V1 +history. Such a claim requires a new wire contract rather than a change to V1 +operation bytes. ## Authority and derivation diff --git a/site/public/spec/v1/store.md b/site/public/spec/v1/store.md new file mode 100644 index 0000000..da7bc4c --- /dev/null +++ b/site/public/spec/v1/store.md @@ -0,0 +1,96 @@ +# Store ports, profiles, and direct libSQL authority + +The graph and operation contract remains V1. This document defines additive +host APIs that preserve those bytes across local SQLite and direct libSQL +authorities. + +## Promise-based store port + +`@hraness/oh/store` has no dependency on `bun:sqlite`. Its methods return +promises and expose: + +- the current exact head; +- a current or historical snapshot at an exact sequence and operation digest; +- a bounded contiguous change page through a pinned head; +- compare-and-swap commit; +- dependency-closure export; +- replay and materialization verification; and +- close. + +A historical read MUST fail if its sequence is absent or identifies a +different operation digest. A change page MUST name its source cursor, pinned +through-head, returned cursor, and whether more operations remain. + +## Semantic bundle ingress + +Model-facing code SHOULD use `OhSemanticBundleIngressV1` instead of generic +record puts. The ingress seals its codec registry, requires a registered codec +for every put kind, parses all values, creates canonical record envelopes, and +submits all puts and tombstones as one compare-and-swap operation. Missing +codecs, invalid values, duplicate keys, stale heads, and incomplete dependency +closures fail before the authority head moves. + +## Profiles and host control + +A binding combines one exact contract, application profile, lifecycle profile, +realm, and space. The built-in canonical profile permits operation replication +and forbids whole-space purge. The built-in working profile forbids operation +replication and permits purge. + +Creating an authority returns separate `store` and `host` objects. The ordinary +store never has a purge method. Trusted control-plane code retains the host +object and does not pass it to an agent tool or model. This is an API and +custody boundary, not protection from code that already holds raw database +credentials or direct filesystem access. + +## Dependency-closure capsules + +A closure export pins the source binding and exact head, sorts unique roots, +and includes exactly the records reachable through declared dependencies. The +capsule digest binds all of those fields. Verification rejects a missing +dependency, an extra unrelated record, a changed record digest, reordered +roots or records, and a false capsule digest. V1 bounds a capsule to 1,024 +roots, 8,192 records, and 67,108,864 canonical UTF-8 bytes. + +A closure is content evidence for a later reviewed adoption. It does not copy +source authority, review state, credentials, or operation history into a +destination. + +## Direct libSQL authority + +`@hraness/oh/libsql` accepts the `execute` and transactional `batch` shape of +`@libsql/client`. It is Node 24 and serverless compatible and does not import +`bun:sqlite`. Unlike the V1 sync transport, it treats libSQL as the current +record and operation authority. + +`bootstrapOhLibSqlAuthorityV1` is the only API that creates schema objects. +Run it in a deployment or migration step with a short-lived schema credential. +`createOhLibSqlStoreAuthorityV1` is the runtime open: it only verifies the +installed schema and contract before reading or creating a bound data space, +so a runtime token does not need schema-change permission. + +Its private implementation tables use the `oh_authority_` prefix: + +| Table | Role | +| --- | --- | +| `oh_authority_schemas` | Exact adapter schema name and digest. | +| `oh_authority_contracts` | Exact Oh contract manifest. | +| `oh_authority_spaces` | Current compare-and-swap heads. | +| `oh_authority_operations` | Canonical append-only operations. | +| `oh_authority_operation_records` | Ordered changes per operation. | +| `oh_authority_records` | Current record materialization. | +| `oh_authority_dependencies` | Current dependency edges. | +| `oh_authority_bindings` | Realm and profile control metadata. | +| `oh_authority_purges` | Minimal whole-space purge receipts. | +| `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. | + +A remote commit reads one exact snapshot, computes the ordinary V1 operation, +then uses one write batch guarded by the expected head. The final guard aborts +the complete transaction when compare-and-swap did not settle at the declared +operation. The adapter re-reads and verifies the persisted canonical operation +before returning success. + +Remote purge similarly inserts a receipt only for the expected working head, +deletes every payload and materialization row under that receipt in the same +write batch, and aborts if either the receipt or deletion is incomplete. A +later open returns the stored purge receipt instead of recreating the space. diff --git a/spec/README.md b/spec/README.md index 9e4b1b5..85b3fbf 100644 --- a/spec/README.md +++ b/spec/README.md @@ -2,8 +2,8 @@ This directory is the versioned public contract for Oh. It defines the canonical bytes, ontology identities, graph envelopes, schema revisions, -SQLite state, operation sync, and local embedding profile that independent -implementations need to interoperate. +SQLite and direct libSQL authority, operation sync, store profiles, and local +embedding profile that independent implementations need to interoperate. [`manifest.json`](manifest.json) is the discovery document. V1 is current and binds these versions: @@ -14,7 +14,7 @@ binds these versions: | Contract ID | `oh.ontology.v1` | | Graph format | `1` | | Schema format | `1` | -| SQLite schema | `1` | +| SQLite schema | `2` | | Sync protocol | `oh.sync.v1` | | Embedding profile | `1` | @@ -25,6 +25,7 @@ binds these versions: - [Schema evolution](v1/schema-evolution.md) - [Graph and operations](v1/graph.md) - [SQLite storage](v1/storage.md) +- [Store ports, profiles, and direct libSQL authority](v1/store.md) - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) - [Derived projections](v1/projection.md) diff --git a/spec/v1/migration.md b/spec/v1/migration.md index 1e7d701..05a0ea4 100644 --- a/spec/v1/migration.md +++ b/spec/v1/migration.md @@ -77,3 +77,16 @@ rollback depend on reversing a content or identifier rewrite. SQLite schema migrations are separate from space transfer. The store records each applied migration name and SQL digest in `oh_migrations` and refuses to open when an applied version has different migration bytes. + +SQLite schema version 2 appends `0002_store_realms` without changing the +released `0001_oh_core` SQL. Existing spaces remain unbound after upgrade. A +host may bind one through the promise-based store authority; once persisted, +the exact realm and profile bytes cannot be replaced. A purged working space +cannot be used as a migration source or recreated under the same identifier. + +The direct libSQL authority has its own `oh_authority_` schema digest. It emits +the same V1 record and operation bytes, but it is not a destination for the +offline CLI import procedure above. Applications moving authority between +adapters MUST prove an exact complete operation chain and matching head through +a separately reviewed migration workflow. A dependency-closure capsule is a +selective content export for adoption, not proof of full authority migration. diff --git a/spec/v1/storage.md b/spec/v1/storage.md index 085d4c4..8cc36e5 100644 --- a/spec/v1/storage.md +++ b/spec/v1/storage.md @@ -1,6 +1,6 @@ # SQLite storage V1 -SQLite schema version `1` is the local authority for an Oh space. The default +SQLite schema version `2` is the local authority for an Oh space. The default CLI database is `.oh/oh.sqlite`; callers may select another path or use an in-memory database. @@ -34,10 +34,37 @@ rolls back the transaction and preserves the original error. | `oh_sync_state` | Last settled state for a named remote. | | `oh_search_documents` | Derived keyword text bound to a record digest. | | `oh_search_fts` | Derived FTS5 index. | +| `oh_space_bindings` | Host-selected realm, lifecycle profile, capabilities, and application-profile digest for a supported store port. | +| `oh_space_purges` | Minimal receipt that permanently reserves the identifier of a purged working space. | -The first migration is named `0001_oh_core`. An implementation MUST store and -check the exact SHA-256 digest of applied migration SQL. It MUST refuse to run -when the same migration version or name has different bytes. +The first migration is named `0001_oh_core`; its released bytes remain +unchanged. Schema version 2 adds `0002_store_realms`. An implementation MUST +store and check the exact SHA-256 digest of applied migration SQL. It MUST +refuse to run when the same migration version or name has different bytes. + +## Realm profiles and purge + +A promise-based store port MAY bind one space to one host-selected realm and +profile. The binding includes the exact Oh contract digest, an optional +application-profile digest, and declared capabilities. A supported runtime +MUST reject a later attempt to open the same space under different binding +bytes. A working profile disables operation replication and enables only +host-controlled whole-space purge. A canonical profile cannot be purged by +that API. + +Purge deletes the space head, complete operation history, current records, +dependency and operation materializations, sync state, and derived keyword +rows in one immediate transaction. It leaves only a content-free receipt with +the prior head, binding digest, purge instant, and receipt digest. The purged +space identifier cannot be reopened in the same database. A host that deletes +an entire database file MUST retain any required deletion evidence in its own +control plane. + +Realm and profile binding is additive store control metadata. V1 operation +digest preimages do not contain the binding. It therefore protects supported +opens and operations but is not a portable cryptographic claim about a V1 +history. Such a claim requires a new wire contract rather than a change to V1 +operation bytes. ## Authority and derivation diff --git a/spec/v1/store.md b/spec/v1/store.md new file mode 100644 index 0000000..da7bc4c --- /dev/null +++ b/spec/v1/store.md @@ -0,0 +1,96 @@ +# Store ports, profiles, and direct libSQL authority + +The graph and operation contract remains V1. This document defines additive +host APIs that preserve those bytes across local SQLite and direct libSQL +authorities. + +## Promise-based store port + +`@hraness/oh/store` has no dependency on `bun:sqlite`. Its methods return +promises and expose: + +- the current exact head; +- a current or historical snapshot at an exact sequence and operation digest; +- a bounded contiguous change page through a pinned head; +- compare-and-swap commit; +- dependency-closure export; +- replay and materialization verification; and +- close. + +A historical read MUST fail if its sequence is absent or identifies a +different operation digest. A change page MUST name its source cursor, pinned +through-head, returned cursor, and whether more operations remain. + +## Semantic bundle ingress + +Model-facing code SHOULD use `OhSemanticBundleIngressV1` instead of generic +record puts. The ingress seals its codec registry, requires a registered codec +for every put kind, parses all values, creates canonical record envelopes, and +submits all puts and tombstones as one compare-and-swap operation. Missing +codecs, invalid values, duplicate keys, stale heads, and incomplete dependency +closures fail before the authority head moves. + +## Profiles and host control + +A binding combines one exact contract, application profile, lifecycle profile, +realm, and space. The built-in canonical profile permits operation replication +and forbids whole-space purge. The built-in working profile forbids operation +replication and permits purge. + +Creating an authority returns separate `store` and `host` objects. The ordinary +store never has a purge method. Trusted control-plane code retains the host +object and does not pass it to an agent tool or model. This is an API and +custody boundary, not protection from code that already holds raw database +credentials or direct filesystem access. + +## Dependency-closure capsules + +A closure export pins the source binding and exact head, sorts unique roots, +and includes exactly the records reachable through declared dependencies. The +capsule digest binds all of those fields. Verification rejects a missing +dependency, an extra unrelated record, a changed record digest, reordered +roots or records, and a false capsule digest. V1 bounds a capsule to 1,024 +roots, 8,192 records, and 67,108,864 canonical UTF-8 bytes. + +A closure is content evidence for a later reviewed adoption. It does not copy +source authority, review state, credentials, or operation history into a +destination. + +## Direct libSQL authority + +`@hraness/oh/libsql` accepts the `execute` and transactional `batch` shape of +`@libsql/client`. It is Node 24 and serverless compatible and does not import +`bun:sqlite`. Unlike the V1 sync transport, it treats libSQL as the current +record and operation authority. + +`bootstrapOhLibSqlAuthorityV1` is the only API that creates schema objects. +Run it in a deployment or migration step with a short-lived schema credential. +`createOhLibSqlStoreAuthorityV1` is the runtime open: it only verifies the +installed schema and contract before reading or creating a bound data space, +so a runtime token does not need schema-change permission. + +Its private implementation tables use the `oh_authority_` prefix: + +| Table | Role | +| --- | --- | +| `oh_authority_schemas` | Exact adapter schema name and digest. | +| `oh_authority_contracts` | Exact Oh contract manifest. | +| `oh_authority_spaces` | Current compare-and-swap heads. | +| `oh_authority_operations` | Canonical append-only operations. | +| `oh_authority_operation_records` | Ordered changes per operation. | +| `oh_authority_records` | Current record materialization. | +| `oh_authority_dependencies` | Current dependency edges. | +| `oh_authority_bindings` | Realm and profile control metadata. | +| `oh_authority_purges` | Minimal whole-space purge receipts. | +| `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. | + +A remote commit reads one exact snapshot, computes the ordinary V1 operation, +then uses one write batch guarded by the expected head. The final guard aborts +the complete transaction when compare-and-swap did not settle at the declared +operation. The adapter re-reads and verifies the persisted canonical operation +before returning success. + +Remote purge similarly inserts a receipt only for the expected working head, +deletes every payload and materialization row under that receipt in the same +write batch, and aborts if either the receipt or deletion is incomplete. A +later open returns the stored purge receipt instead of recreating the space. diff --git a/src/cli.test.ts b/src/cli.test.ts index bb86020..e3e80c9 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -93,7 +93,7 @@ describe("oh CLI", () => { expect(result.code).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ manifest: { contractId: "oh.ontology.v1" }, - sqliteSchemaVersion: 1, + sqliteSchemaVersion: 2, v: 1, }); expect(existsSync(join(root, ".oh"))).toBe(false); diff --git a/src/contract.ts b/src/contract.ts index 9ea18f7..e626dd5 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -44,10 +44,12 @@ export type OhRecordCodec = Readonly<{ /** Optional semantic validation layered over the immutable generic record envelope. */ export class OhRecordCodecRegistry { readonly #codecs = new Map(); + #sealed = false; register(codec: OhRecordCodec): this { + if (this.#sealed) throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } @@ -60,4 +62,26 @@ export class OhRecordCodecRegistry { has(kind: KnowledgeGraphRecordKindV1): boolean { return this.#codecs.has(kind); } + + /** Parses only through an explicitly registered codec. */ + parseRequired(kind: KnowledgeGraphRecordKindV1, value: unknown): JsonValue | null { + const codec = this.#codecs.get(kind); + if (codec === undefined) return null; + try { + const parsed = codec.parse(value); + if (parsed === null) return null; + canonicalJson(parsed); + return parsed; + } catch { return null; } + } + + /** Prevents the validation policy from changing after an ingress is created. */ + seal(): this { + this.#sealed = true; + return this; + } + + get sealed(): boolean { + return this.#sealed; + } } diff --git a/src/index.ts b/src/index.ts index 2d2ab6a..8424d86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export * from "./graph"; export * from "./ontology"; export * from "./operation"; export * from "./schema"; +export * from "./store"; export * from "./sync"; diff --git a/src/libsql.test.ts b/src/libsql.test.ts new file mode 100644 index 0000000..ebe840f --- /dev/null +++ b/src/libsql.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { Database, type SQLQueryBindings } from "bun:sqlite"; + +import { createKnowledgeGraphRecordV1 } from "./graph"; +import { + bootstrapOhLibSqlAuthorityV1, + createOhLibSqlStoreAuthorityV1, + type OhLibSqlClientV1, + type OhLibSqlResultV1, + type OhLibSqlStatementV1, +} from "./libsql"; +import { + OH_CANONICAL_STORE_PROFILE_V1, + OH_WORKING_STORE_PROFILE_V1, + OhConflictError, + OhProfileError, + OhPurgedSpaceError, +} from "./store"; + +class SqliteCompatibleLibSqlClient implements OhLibSqlClientV1 { + readonly database = new Database(":memory:", { strict: true }); + + #execute(statement: OhLibSqlStatementV1 | string): OhLibSqlResultV1 { + const sql = typeof statement === "string" ? statement : statement.sql; + const args = typeof statement === "string" ? [] : statement.args ?? []; + const bindings: SQLQueryBindings[] = args.map((value) => value instanceof Date + ? value.toISOString() : value instanceof ArrayBuffer ? new Uint8Array(value) : value); + if (/^\s*(?:SELECT|PRAGMA)\b/iu.test(sql)) { + return { rows: this.database.query, SQLQueryBindings[]>(sql).all(...bindings) }; + } + const result = this.database.query(sql).run(...bindings); + return { rows: [], rowsAffected: result.changes }; + } + + async execute(statement: OhLibSqlStatementV1 | string): Promise { + return this.#execute(statement); + } + + async batch( + statements: readonly OhLibSqlStatementV1[], + _mode?: "deferred" | "read" | "write", + ): Promise { + return this.database.transaction((items: readonly OhLibSqlStatementV1[]) => + items.map((statement) => this.#execute(statement)))(statements); + } + + close(): void { this.database.close(); } +} + +const entity = (key: string, name: string, dependencies: readonly string[] = []) => + createKnowledgeGraphRecordV1({ dependencies, key, kind: "entity", v: 1, value: { name } }); + +async function bootstrappedClient(): Promise { + const client = new SqliteCompatibleLibSqlClient(); + expect(await bootstrapOhLibSqlAuthorityV1(client)).toMatchObject({ schemaVersion: 1, v: 1 }); + return client; +} + +describe("direct libSQL Oh authority", () => { + test("does not bootstrap schema as a side effect of the runtime open", async () => { + const client = new SqliteCompatibleLibSqlClient(); + await expect(createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:missing", spaceId: "missing", + })).rejects.toThrow(); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count FROM sqlite_schema + WHERE name LIKE 'oh_authority_%'`).get()?.count).toBe(0); + client.close(); + }); + + test("opens with a data-only runtime client after one explicit schema bootstrap", async () => { + const schemaClient = await bootstrappedClient(); + const rejectDdl = (statement: OhLibSqlStatementV1 | string): void => { + const sql = typeof statement === "string" ? statement : statement.sql; + if (/^\s*(?:ALTER|CREATE|DROP|REINDEX|VACUUM)\b/iu.test(sql)) { + throw new Error("runtime credential cannot execute schema DDL"); + } + }; + const runtimeClient: OhLibSqlClientV1 = { + execute: async (statement) => { + rejectDdl(statement); + return await schemaClient.execute(statement); + }, + batch: async (statements, mode) => { + statements.forEach(rejectDdl); + return await schemaClient.batch(statements, mode); + }, + }; + const authority = await createOhLibSqlStoreAuthorityV1(runtimeClient, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:data-only", spaceId: "data-only", + }); + expect((await authority.store.head()).sequence).toBe(0); + await authority.store.close(); + schemaClient.close(); + }); + + test("is an async authoritative store rather than an operation-sync cache", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:remote", spaceId: "remote", + }); + const parent = entity("entity:parent", "Parent"); + const child = entity("entity:child", "Child", [parent.key]); + const first = await authority.store.commit({ actorId: "agent.remote", changes: [ + { kind: "put", record: parent, v: 1 }, { kind: "put", record: child, v: 1 }, + ], expectedHead: await authority.store.head(), instant: "2026-08-29T12:00:00.000Z", + operationId: "op_remote_one" }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:later", "Later"), v: 1 }], expectedHead: await authority.store.head(), + instant: "2026-08-29T12:01:00.000Z", operationId: "op_remote_two" }); + + expect((await authority.store.snapshot({ head: { + operationSha256: first.operationSha256, sequence: first.sequence } })).records) + .toEqual([child, parent].sort((left, right) => left.key.localeCompare(right.key))); + expect((await authority.store.exportDependencyClosure({ roots: [child.key] })).records) + .toEqual([child, parent].sort((left, right) => left.key.localeCompare(right.key))); + expect(await authority.store.verify()).toMatchObject({ integrity: "verified", operations: 2, records: 3 }); + + const reopened = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:remote", spaceId: "remote", + }); + expect((await reopened.store.head()).operationSha256).toBe((await authority.store.head()).operationSha256); + await reopened.store.close(); + await authority.store.close(); + client.close(); + }); + + test("uses compare-and-swap guards to leave no partial remote mutation", async () => { + const client = await bootstrappedClient(); + const first = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:cas", spaceId: "cas", + }); + const second = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:cas", spaceId: "cas", + }); + const stale = await second.store.head(); + await first.store.commit({ actorId: "agent.one", changes: [{ kind: "put", + record: entity("entity:first", "First"), v: 1 }], expectedHead: stale, + operationId: "op_first" }); + await expect(second.store.commit({ actorId: "agent.two", changes: [{ kind: "put", + record: entity("entity:stale", "Stale"), v: 1 }], expectedHead: stale, + operationId: "op_stale" })).rejects.toThrow(OhConflictError); + expect((await first.store.snapshot()).records.map(({ key }) => key)).toEqual(["entity:first"]); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_operations WHERE operation_id = 'op_stale'`).get()?.count).toBe(0); + await first.store.close(); await second.store.close(); client.close(); + }); + + test("rejects profile drift and permanently purges a working realm with a receipt", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:purge", spaceId: "purge", + }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:private", "Private"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_private" }); + await expect(createOhLibSqlStoreAuthorityV1(client, { + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:other", spaceId: "purge", + })).rejects.toThrow(OhProfileError); + + const receipt = await authority.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" }); + expect(receipt.priorHead.sequence).toBe(1); + expect("purgeWorkingSpace" in authority.store).toBe(false); + for (const table of ["oh_authority_spaces", "oh_authority_bindings", "oh_authority_operations", + "oh_authority_operation_records", "oh_authority_records", "oh_authority_dependencies"]) { + const count = client.database.query<{ count: number }, []>(`SELECT count(*) AS count FROM ${table}`).get()?.count; + expect(count, table).toBe(0); + } + expect(client.database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM oh_authority_purges WHERE space_id = 'purge'", + ).get()?.count).toBe(1); + await expect(createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:purge", spaceId: "purge", + })).rejects.toThrow(OhPurgedSpaceError); + client.close(); + }); + + test("does not grant canonical stores a purge path", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:canonical", spaceId: "canonical", + }); + await expect(authority.host.purgeWorkingSpace({})).rejects.toThrow(OhProfileError); + await authority.store.close(); client.close(); + }); +}); diff --git a/src/libsql.ts b/src/libsql.ts new file mode 100644 index 0000000..f3bd60e --- /dev/null +++ b/src/libsql.ts @@ -0,0 +1,655 @@ +import { + canonicalJson, + canonicalNow, + canonicalSha256, + parseSha256Hex, + safeCode, + type Sha256Hex, +} from "./canonical"; +import { OH_CONTRACT_MANIFEST_V1 } from "./contract"; +import { canonicalKnowledgeGraphChangesV1, parseKnowledgeGraphRecordV1, + type KnowledgeGraphRecordV1 } from "./graph"; +import { parseOhOperationV1, type OhOperationV1 } from "./operation"; +import { + createOhDependencyClosureV1, + createOhSpacePurgeReceiptV1, + createOhStoreBindingV1, + emptyOhHeadV1, + OH_CANONICAL_STORE_PROFILE_V1, + OhConflictError, + OhIntegrityError, + OhProfileError, + OhPurgedSpaceError, + parseOhHeadRefV1, + parseOhSpacePurgeReceiptV1, + parseOhStoreBindingV1, + parseOhStoreProfileV1, + replayOhOperationsV1, + transitionOhSnapshotV1, + type OhChangesPageV1, + type OhCommitInputV1, + type OhDependencyClosureV1, + type OhHeadRefV1, + type OhHeadV1, + type OhSnapshotV1, + type OhSpacePurgeReceiptV1, + type OhStoreAuthorityV1, + type OhStoreBindingV1, + type OhStoreHostControlV1, + type OhStoreProfileV1, + type OhStoreV1, + type OhStoreVerificationV1, +} from "./store"; + +export type OhLibSqlValueV1 = ArrayBuffer | Date | Uint8Array | bigint | boolean | null | number | string; +export type OhLibSqlStatementV1 = Readonly<{ args?: readonly OhLibSqlValueV1[]; sql: string }>; +export type OhLibSqlResultV1 = Readonly<{ + rows: readonly (Readonly> | readonly unknown[])[]; + rowsAffected?: number; +}>; + +/** Structural subset implemented by `@libsql/client` clients. */ +export interface OhLibSqlClientV1 { + batch( + statements: readonly OhLibSqlStatementV1[], + mode?: "deferred" | "read" | "write", + ): Promise; + close?(): void; + execute(statement: OhLibSqlStatementV1 | string): Promise; +} + +export type OhLibSqlStoreAuthorityOptionsV1 = Readonly<{ + closeClient?: boolean; + profile?: OhStoreProfileV1; + realmId?: string; + spaceId?: string; +}>; + +const AUTHORITY_SCHEMA_NAME = "oh.libsql-authority.v1"; +const AUTHORITY_SCHEMA_VERSION = 1; +const EMPTY_RECORDS_SHA256 = canonicalSha256([]); + +const AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ + `CREATE TABLE IF NOT EXISTS oh_authority_contracts ( + contract_id TEXT PRIMARY KEY, + contract_sha256 TEXT NOT NULL, + manifest_json TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_spaces ( + space_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK(generation >= 0), + head_operation_sha256 TEXT, + graph_revision_sha256 TEXT, + records_sha256 TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK(generation = sequence) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_operations ( + operation_sha256 TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence > 0), + operation_id TEXT NOT NULL, + parent_operation_sha256 TEXT, + graph_revision_sha256 TEXT NOT NULL, + records_sha256 TEXT NOT NULL, + operation_json TEXT NOT NULL, + instant TEXT NOT NULL, + UNIQUE(space_id, sequence), + UNIQUE(space_id, operation_id) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_operation_records ( + operation_sha256 TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK(ordinal >= 0), + record_key TEXT NOT NULL, + change_kind TEXT NOT NULL CHECK(change_kind IN ('put', 'tombstone')), + record_sha256 TEXT NOT NULL, + PRIMARY KEY(operation_sha256, ordinal), + UNIQUE(operation_sha256, record_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_records ( + space_id TEXT NOT NULL, + record_key TEXT NOT NULL, + kind TEXT NOT NULL, + record_sha256 TEXT NOT NULL, + record_json TEXT NOT NULL, + operation_sha256 TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence > 0), + PRIMARY KEY(space_id, record_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_dependencies ( + space_id TEXT NOT NULL, + record_key TEXT NOT NULL, + dependency_key TEXT NOT NULL, + PRIMARY KEY(space_id, record_key, dependency_key) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_bindings ( + space_id TEXT PRIMARY KEY, + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL, + binding_sha256 TEXT NOT NULL UNIQUE, + binding_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL, + prior_operation_sha256 TEXT, + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE, + receipt_json TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS oh_authority_commit_guards ( + value TEXT NOT NULL CHECK(value = 'ok') + ) STRICT`, + "CREATE INDEX IF NOT EXISTS oh_authority_operations_space_sequence ON oh_authority_operations(space_id, sequence)", + "CREATE INDEX IF NOT EXISTS oh_authority_records_space_kind ON oh_authority_records(space_id, kind, record_key)", + "CREATE INDEX IF NOT EXISTS oh_authority_dependencies_dependency ON oh_authority_dependencies(space_id, dependency_key)", +]); + +const AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_STATEMENTS); + +function rowValue( + row: Readonly> | readonly unknown[], + key: string, + index: number, +): unknown { + return Array.isArray(row) ? row[index] : (row as Readonly>)[key]; +} + +function integer(value: unknown): number | null { + const parsed = typeof value === "bigint" ? Number(value) : Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +function normalizeLimit(value: number | undefined, fallback = 100, maximum = 1000): number { + const limit = value ?? fallback; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { + throw new RangeError(`limit must be an integer from 1 through ${maximum}.`); + } + return limit; +} + +function parseOperationJson(value: unknown): OhOperationV1 { + if (typeof value !== "string") throw new OhIntegrityError("A stored operation is not JSON text."); + let parsedValue: unknown; + try { parsedValue = JSON.parse(value); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } + const operation = parseOhOperationV1(parsedValue); + if (operation === null || canonicalJson(operation) !== value) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; +} + +function parseHeadRow(row: Readonly> | readonly unknown[]): OhHeadV1 { + const generation = integer(rowValue(row, "generation", 0)); + const graphValue = rowValue(row, "graph_revision_sha256", 1); + const operationValue = rowValue(row, "head_operation_sha256", 2); + const recordsSha256 = parseSha256Hex(rowValue(row, "records_sha256", 3)); + const sequence = integer(rowValue(row, "sequence", 4)); + const graphRevisionSha256 = graphValue === null ? null : parseSha256Hex(graphValue); + const operationSha256 = operationValue === null ? null : parseSha256Hex(operationValue); + if (generation === null || sequence === null || generation !== sequence || recordsSha256 === null + || (graphValue !== null && graphRevisionSha256 === null) + || (operationValue !== null && operationSha256 === null) + || ((sequence === 0) !== (operationSha256 === null)) + || ((sequence === 0) !== (graphRevisionSha256 === null))) { + throw new OhIntegrityError("The remote authority contains an invalid head."); + } + return { generation, graphRevisionSha256, operationSha256, recordsSha256, sequence, v: 1 }; +} + +async function queryOne( + client: OhLibSqlClientV1, + statement: OhLibSqlStatementV1, +): Promise> | readonly unknown[] | null> { + return (await client.execute(statement)).rows[0] ?? null; +} + +async function verifyAuthoritySchema(client: OhLibSqlClientV1): Promise { + const installed = await queryOne(client, { sql: `SELECT name, schema_sha256 + FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); + if (installed === null || rowValue(installed, "name", 0) !== AUTHORITY_SCHEMA_NAME + || rowValue(installed, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256) { + throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + } + const contract = await queryOne(client, { sql: `SELECT contract_sha256, manifest_json + FROM oh_authority_contracts WHERE contract_id = ?`, args: [OH_CONTRACT_MANIFEST_V1.contractId] }); + if (contract === null || rowValue(contract, "contract_sha256", 0) !== OH_CONTRACT_MANIFEST_V1.contractSha256 + || rowValue(contract, "manifest_json", 1) !== canonicalJson(OH_CONTRACT_MANIFEST_V1)) { + throw new OhIntegrityError("The remote authority contract differs from this runtime."); + } +} + +/** One-time schema operation for a client authorized to create authority tables. */ +export async function bootstrapOhLibSqlAuthorityV1( + client: OhLibSqlClientV1, +): Promise> { + await client.execute(`CREATE TABLE IF NOT EXISTS oh_authority_schemas ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + schema_sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL + ) STRICT`); + const applied = await queryOne(client, { sql: `SELECT name, schema_sha256 + FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); + if (applied !== null && (rowValue(applied, "name", 0) !== AUTHORITY_SCHEMA_NAME + || rowValue(applied, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256)) { + throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + } + const setup: OhLibSqlStatementV1[] = AUTHORITY_SCHEMA_STATEMENTS.map((sql) => ({ sql })); + setup.push({ sql: `INSERT INTO oh_authority_schemas(version, name, schema_sha256, applied_at) + VALUES (?, ?, ?, ?) ON CONFLICT(version) DO NOTHING`, + args: [AUTHORITY_SCHEMA_VERSION, AUTHORITY_SCHEMA_NAME, AUTHORITY_SCHEMA_SHA256, canonicalNow()] }); + setup.push({ sql: `INSERT INTO oh_authority_contracts(contract_id, contract_sha256, manifest_json) + VALUES (?, ?, ?) ON CONFLICT(contract_id) DO NOTHING`, args: [OH_CONTRACT_MANIFEST_V1.contractId, + OH_CONTRACT_MANIFEST_V1.contractSha256, canonicalJson(OH_CONTRACT_MANIFEST_V1)] }); + await client.batch(setup, "write"); + await verifyAuthoritySchema(client); + return { schemaSha256: AUTHORITY_SCHEMA_SHA256, schemaVersion: 1, v: 1 }; +} + +async function initializeSpace( + client: OhLibSqlClientV1, + binding: OhStoreBindingV1, +): Promise { + const purged = await queryOne(client, { sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + args: [binding.spaceId] }); + if (purged !== null) { + const json = rowValue(purged, "receipt_json", 0); + if (typeof json !== "string") throw new OhIntegrityError("A remote purge receipt is invalid."); + const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); + if (receipt === null || canonicalJson(receipt) !== json) throw new OhIntegrityError("A remote purge receipt is invalid."); + throw new OhPurgedSpaceError(receipt); + } + const now = canonicalNow(); + await client.batch([ + { sql: `INSERT INTO oh_authority_spaces(space_id, contract_id, generation, + head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at) + VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`, + args: [binding.spaceId, OH_CONTRACT_MANIFEST_V1.contractId, EMPTY_RECORDS_SHA256, now, now] }, + { sql: `INSERT INTO oh_authority_bindings(space_id, realm_id, profile_id, profile_kind, + profile_sha256, binding_sha256, binding_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`, + args: [binding.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, + binding.profile.profileSha256, binding.bindingSha256, canonicalJson(binding), now] }, + ], "write"); + const persisted = await queryOne(client, { sql: "SELECT binding_json FROM oh_authority_bindings WHERE space_id = ?", + args: [binding.spaceId] }); + if (persisted === null || rowValue(persisted, "binding_json", 0) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is already bound to a different realm or profile."); + } +} + +class OhLibSqlStoreV1 implements OhStoreV1 { + readonly binding: OhStoreBindingV1; + readonly #client: OhLibSqlClientV1; + readonly #closeClient: boolean; + #closed = false; + #purged: OhSpacePurgeReceiptV1 | null = null; + + constructor(client: OhLibSqlClientV1, binding: OhStoreBindingV1, closeClient: boolean) { + this.#client = client; + this.binding = binding; + this.#closeClient = closeClient; + } + + #assertOpen(): void { + if (this.#purged !== null) throw new OhPurgedSpaceError(this.#purged); + if (this.#closed) throw new Error("The Oh libSQL store is closed."); + } + + async head(): Promise { + this.#assertOpen(); + const row = await queryOne(this.#client, { sql: `SELECT generation, graph_revision_sha256, + head_operation_sha256, records_sha256, sequence FROM oh_authority_spaces WHERE space_id = ?`, + args: [this.binding.spaceId] }); + if (row === null) { + const purged = await this.#readPurge(); + if (purged !== null) { this.#purged = purged; throw new OhPurgedSpaceError(purged); } + throw new OhIntegrityError("The remote Oh space does not exist."); + } + return parseHeadRow(row); + } + + async #readPurge(): Promise { + const row = await queryOne(this.#client, { sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + args: [this.binding.spaceId] }); + if (row === null) return null; + const json = rowValue(row, "receipt_json", 0); + if (typeof json !== "string") throw new OhIntegrityError("A remote purge receipt is invalid."); + const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); + if (receipt === null || canonicalJson(receipt) !== json) throw new OhIntegrityError("A remote purge receipt is invalid."); + return receipt; + } + + async #headAt(reference: OhHeadRefV1): Promise { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) return emptyOhHeadV1(); + const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, parsed.sequence] }); + if (row === null) throw new OhConflictError("The requested head is not present in this space."); + const operation = parseOperationJson(rowValue(row, "operation_json", 0)); + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256: operation.recordsSha256, + sequence: operation.sequence, v: 1 }; + } + + async snapshot(options: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }> = {}): Promise { + this.#assertOpen(); + const current = await this.head(); + const target = options.head === undefined ? current : await this.#headAt(options.head); + if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); + const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`, args: [this.binding.spaceId, target.sequence] })).rows; + const operations = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, options.maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 + || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Remote operation replay does not reproduce the requested head."); + } + return snapshot; + } + + async changesSince( + fromValue: OhHeadRefV1, + options: Readonly<{ limit?: number; through?: OhHeadRefV1 }> = {}, + ): Promise { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit); + const current = await this.head(); + const fromHead = await this.#headAt(from); + const through = options.through === undefined ? current : await this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one remote history prefix."); + } + const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, + args: [this.binding.spaceId, fromHead.sequence, through.sequence, limit + 1] })).rows; + const parsed = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + let prior: OhHeadRefV1 = fromHead; + for (const operation of operations) { + if (operation.sequence !== prior.sequence + 1 + || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The remote change feed contains a gap or fork."); + } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + return { from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, operations, through, to: prior, v: 1 }; + } + + async #assertMaterializedSnapshot(snapshot: OhSnapshotV1): Promise { + const rows = (await this.#client.execute({ sql: `SELECT record_json FROM oh_authority_records + WHERE space_id = ? ORDER BY record_key`, args: [this.binding.spaceId] })).rows; + const records: KnowledgeGraphRecordV1[] = rows.map((row) => { + const json = rowValue(row, "record_json", 0); + if (typeof json !== "string") throw new OhIntegrityError("A materialized remote record is not JSON text."); + const parsed = parseKnowledgeGraphRecordV1(JSON.parse(json)); + if (parsed === null || canonicalJson(parsed) !== json) throw new OhIntegrityError("A materialized remote record is invalid."); + return parsed; + }); + if (canonicalJson(records) !== canonicalJson(snapshot.records)) { + throw new OhIntegrityError("Remote materialized records do not match operation replay."); + } + const dependencyRows = (await this.#client.execute({ sql: `SELECT record_key, dependency_key + FROM oh_authority_dependencies WHERE space_id = ? ORDER BY record_key, dependency_key`, + args: [this.binding.spaceId] })).rows.map((row) => ({ + dependency_key: rowValue(row, "dependency_key", 1), + record_key: rowValue(row, "record_key", 0), + })); + const expectedDependencies = snapshot.records.flatMap((record) => + record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key }))); + if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { + throw new OhIntegrityError("Remote materialized dependencies do not match operation replay."); + } + } + + async #operationById(operationId: string): Promise { + const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); + return row === null ? null : parseOperationJson(rowValue(row, "operation_json", 0)); + } + + async commit(input: OhCommitInputV1): Promise { + this.#assertOpen(); + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || changes.length === 0) throw new TypeError("Invalid Oh commit input."); + const duplicate = await this.#operationById(operationId); + if (duplicate !== null) { + if (duplicate.actorId !== actorId || canonicalJson(duplicate.changes) !== canonicalJson(changes)) { + throw new OhConflictError("The operation ID is already bound to different content."); + } + return duplicate; + } + const current = await this.head(); + if (!Number.isSafeInteger(input.expectedHead.generation) || input.expectedHead.generation < 0 + || current.generation !== input.expectedHead.generation + || current.operationSha256 !== input.expectedHead.operationSha256) { + throw new OhConflictError("The expected head does not match the current remote space head."); + } + const snapshot = await this.snapshot({ head: current }); + await this.#assertMaterializedSnapshot(snapshot); + const transition = transitionOhSnapshotV1({ actorId, changes, + instant: input.instant ?? canonicalNow(), operationId, snapshot, spaceId: this.binding.spaceId }); + const operation = transition.operation; + const existsOperation = "EXISTS (SELECT 1 FROM oh_authority_operations WHERE operation_sha256 = ?)"; + const statements: OhLibSqlStatementV1[] = [{ + sql: `INSERT INTO oh_authority_operations(operation_sha256, space_id, sequence, + operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, + operation_json, instant) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ?)`, + args: [operation.operationSha256, this.binding.spaceId, operation.sequence, operation.operationId, + operation.parentOperationSha256, operation.graphRevisionSha256, operation.recordsSha256, + canonicalJson(operation), operation.instant, this.binding.spaceId, current.generation, + current.operationSha256], + }]; + for (const [ordinal, change] of operation.changes.entries()) { + const key = change.kind === "put" ? change.record.key : change.key; + const digest = change.kind === "put" ? change.record.recordSha256 : change.priorSha256; + statements.push({ sql: `INSERT INTO oh_authority_operation_records(operation_sha256, + ordinal, record_key, change_kind, record_sha256) + SELECT ?, ?, ?, ?, ? WHERE ${existsOperation}`, + args: [operation.operationSha256, ordinal, key, change.kind, digest, operation.operationSha256] }); + statements.push({ sql: `DELETE FROM oh_authority_dependencies WHERE space_id = ? AND record_key = ? + AND ${existsOperation}`, args: [this.binding.spaceId, key, operation.operationSha256] }); + if (change.kind === "put") { + statements.push({ sql: `INSERT INTO oh_authority_records(space_id, record_key, kind, + record_sha256, record_json, operation_sha256, sequence) + SELECT ?, ?, ?, ?, ?, ?, ? WHERE ${existsOperation} + ON CONFLICT(space_id, record_key) DO UPDATE SET kind = excluded.kind, + record_sha256 = excluded.record_sha256, record_json = excluded.record_json, + operation_sha256 = excluded.operation_sha256, sequence = excluded.sequence`, + args: [this.binding.spaceId, key, change.record.kind, change.record.recordSha256, + canonicalJson(change.record), operation.operationSha256, operation.sequence, + operation.operationSha256] }); + } else { + statements.push({ sql: `DELETE FROM oh_authority_records WHERE space_id = ? AND record_key = ? + AND record_sha256 = ? AND ${existsOperation}`, + args: [this.binding.spaceId, key, change.priorSha256, operation.operationSha256] }); + } + } + for (const change of operation.changes) { + if (change.kind !== "put") continue; + for (const dependency of change.record.dependencies) { + statements.push({ sql: `INSERT INTO oh_authority_dependencies(space_id, record_key, dependency_key) + SELECT ?, ?, ? WHERE ${existsOperation}`, + args: [this.binding.spaceId, change.record.key, dependency, operation.operationSha256] }); + } + } + statements.push({ sql: `UPDATE oh_authority_spaces SET generation = ?, head_operation_sha256 = ?, + graph_revision_sha256 = ?, records_sha256 = ?, sequence = ?, updated_at = ? + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ? AND ${existsOperation}`, + args: [operation.sequence, operation.operationSha256, operation.graphRevisionSha256, + operation.recordsSha256, operation.sequence, operation.instant, this.binding.spaceId, + current.generation, current.operationSha256, operation.operationSha256] }); + statements.push({ sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE NOT EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 = ?)`, + args: [this.binding.spaceId, operation.sequence, operation.operationSha256] }); + try { + await this.#client.batch(statements, "write"); + } catch (error) { + const raced = await this.#operationById(operationId); + if (raced !== null && raced.actorId === actorId + && canonicalJson(raced.changes) === canonicalJson(changes)) return raced; + const head = await this.head(); + if (head.operationSha256 !== current.operationSha256) { + throw new OhConflictError("The remote space head changed while committing."); + } + throw error; + } + const persisted = await this.#operationById(operationId); + if (persisted === null || canonicalJson(persisted) !== canonicalJson(operation)) { + throw new OhIntegrityError("The remote authority did not persist the committed operation exactly."); + } + return persisted; + } + + async exportDependencyClosure(input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): Promise { + if (!this.binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This remote profile does not permit dependency-closure export."); + } + const snapshot = await this.snapshot({ ...(input.head === undefined ? {} : { head: input.head }), + ...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }) }); + return createOhDependencyClosureV1({ binding: this.binding, + ...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }), + roots: input.roots, snapshot }); + } + + async verify(): Promise { + this.#assertOpen(); + const snapshot = await this.snapshot(); + await this.#assertMaterializedSnapshot(snapshot); + const countRow = await queryOne(this.#client, { sql: `SELECT count(*) AS count + FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] }); + const operations = countRow === null ? null : integer(rowValue(countRow, "count", 0)); + if (operations === null || operations !== snapshot.head.sequence) { + throw new OhIntegrityError("Remote operation count does not match its head."); + } + return { head: snapshot.head, integrity: "verified", operations, + records: snapshot.records.length, v: 1 }; + } + + async purgeWorkingSpace(purgedAt: string): Promise { + this.#assertOpen(); + if (this.binding.profile.profileKind !== "working" + || !this.binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + for (let attempt = 0; attempt < 3; attempt += 1) { + const existing = await this.#readPurge(); + if (existing !== null) { this.#purged = existing; return existing; } + const head = await this.head(); + const receipt = createOhSpacePurgeReceiptV1({ binding: this.binding, priorHead: head, purgedAt }); + const receiptExists = "EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ? AND receipt_sha256 = ?)"; + const statements: OhLibSqlStatementV1[] = [{ sql: `INSERT INTO oh_authority_purges(space_id, + binding_sha256, prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM oh_authority_spaces + WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ?) + AND EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ? AND binding_sha256 = ?)`, + args: [this.binding.spaceId, this.binding.bindingSha256, head.operationSha256, head.sequence, + receipt.purgedAt, receipt.receiptSha256, canonicalJson(receipt), this.binding.spaceId, + head.generation, head.operationSha256, this.binding.spaceId, this.binding.bindingSha256] }]; + const guardedDelete = (table: string): OhLibSqlStatementV1 => ({ + sql: `DELETE FROM ${table} WHERE space_id = ? AND ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256], + }); + statements.push({ sql: `DELETE FROM oh_authority_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) + AND ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); + statements.push(guardedDelete("oh_authority_dependencies")); + statements.push(guardedDelete("oh_authority_records")); + statements.push(guardedDelete("oh_authority_operations")); + statements.push(guardedDelete("oh_authority_bindings")); + statements.push(guardedDelete("oh_authority_spaces")); + statements.push({ sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR NOT ${receiptExists}`, + args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); + try { await this.#client.batch(statements, "write"); } catch { + const raced = await this.#readPurge(); + if (raced !== null) { this.#purged = raced; return raced; } + continue; + } + const persisted = await this.#readPurge(); + if (persisted !== null) { this.#purged = persisted; return persisted; } + } + throw new OhConflictError("The remote working space changed repeatedly while purging."); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + if (this.#closeClient) this.#client.close?.(); + } +} + +/** Opens a direct libSQL/Turso authority; this is not operation-log sync. */ +export async function createOhLibSqlStoreAuthorityV1( + client: OhLibSqlClientV1, + options: OhLibSqlStoreAuthorityOptionsV1 = {}, +): Promise { + const profile = parseOhStoreProfileV1(options.profile ?? OH_CANONICAL_STORE_PROFILE_V1); + if (profile === null) throw new TypeError("Invalid libSQL store profile."); + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ profile, + realmId: options.realmId ?? `realm:${spaceId}`, spaceId, v: 1 }); + await verifyAuthoritySchema(client); + await initializeSpace(client, binding); + const authority = new OhLibSqlStoreV1(client, binding, options.closeClient ?? false); + const store: OhStoreV1 = Object.freeze({ + binding, + changesSince: (from: OhHeadRefV1, changeOptions?: Readonly<{ limit?: number; through?: OhHeadRefV1 }>) => + authority.changesSince(from, changeOptions), + close: () => authority.close(), + commit: (input: OhCommitInputV1) => authority.commit(input), + exportDependencyClosure: (input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>) => authority.exportDependencyClosure(input), + head: () => authority.head(), + snapshot: (snapshotOptions?: Readonly<{ head?: OhHeadRefV1; maximumRecords?: number }>) => + authority.snapshot(snapshotOptions), + verify: () => authority.verify(), + }); + let purge: OhSpacePurgeReceiptV1 | null = null; + const host: OhStoreHostControlV1 = Object.freeze({ + binding, + purgeWorkingSpace: async (input: Readonly<{ purgedAt?: string }>) => { + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("This host handle is not bound to a purgeable working profile."); + } + if (purge !== null) return purge; + purge = await authority.purgeWorkingSpace(input.purgedAt ?? canonicalNow()); + return purge; + }, + }); + return Object.freeze({ host, store }); +} diff --git a/src/sqlite/driver.ts b/src/sqlite/driver.ts index 45e239e..81fbcf4 100644 --- a/src/sqlite/driver.ts +++ b/src/sqlite/driver.ts @@ -33,3 +33,15 @@ export function withImmediateTransaction(database: OhSqliteDatabase, work: () throw error; } } + +export function withReadTransaction(database: OhSqliteDatabase, work: () => T): T { + database.exec("BEGIN"); + try { + const result = work(); + database.exec("COMMIT"); + return result; + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve the original failure */ } + throw error; + } +} diff --git a/src/sqlite/index.ts b/src/sqlite/index.ts index 2db1f2d..d6c0eae 100644 --- a/src/sqlite/index.ts +++ b/src/sqlite/index.ts @@ -1,3 +1,4 @@ export * from "./driver"; export * from "./migrations"; +export * from "./port"; export * from "./store"; diff --git a/src/sqlite/migrations.test.ts b/src/sqlite/migrations.test.ts new file mode 100644 index 0000000..1bb6055 --- /dev/null +++ b/src/sqlite/migrations.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { canonicalNow, sha256Hex } from "../canonical"; +import { openOhSqliteDatabase } from "./driver"; +import { OH_SQLITE_MIGRATIONS, OH_SQLITE_SCHEMA_VERSION } from "./migrations"; +import { OhSqliteStore } from "./store"; + +describe("SQLite schema evolution", () => { + test("preserves the released 0001 bytes and appends store realms as version 2", () => { + expect(OH_SQLITE_SCHEMA_VERSION).toBe(2); + expect(OH_SQLITE_MIGRATIONS.map(({ name, version }) => ({ name, version }))).toEqual([ + { name: "0001_oh_core", version: 1 }, + { name: "0002_store_realms", version: 2 }, + ]); + expect(String(sha256Hex(OH_SQLITE_MIGRATIONS[0]?.sql ?? ""))).toBe( + "f525fc4f521b544d8e00526f2993a3b6bf81ae936950765c51406566ea4b1b7c", + ); + }); + + test("upgrades an applied version-1 authority without rewriting its migration evidence", () => { + const database = openOhSqliteDatabase(":memory:"); + const first = OH_SQLITE_MIGRATIONS[0]; + if (first === undefined) throw new Error("Missing first migration."); + database.exec(`CREATE TABLE oh_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + migration_sha256 TEXT NOT NULL CHECK(length(migration_sha256) = 64), + applied_at TEXT NOT NULL + ) STRICT`); + database.exec(first.sql); + database.query(`INSERT INTO oh_migrations(version, name, migration_sha256, applied_at) + VALUES (?, ?, ?, ?)`).run(first.version, first.name, sha256Hex(first.sql), canonicalNow()); + + const store = new OhSqliteStore({ database, spaceId: "upgraded" }); + expect(store.contract().sqliteSchemaVersion).toBe(2); + expect(database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM oh_migrations", + ).get()?.count).toBe(2); + expect(database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM sqlite_schema WHERE name IN ('oh_space_bindings', 'oh_space_purges')", + ).get()?.count).toBe(2); + store.close(); + }); +}); diff --git a/src/sqlite/migrations.ts b/src/sqlite/migrations.ts index 0538b73..a741c44 100644 --- a/src/sqlite/migrations.ts +++ b/src/sqlite/migrations.ts @@ -1,7 +1,7 @@ import { canonicalNow, sha256Hex } from "../canonical"; import type { OhSqliteDatabase } from "./driver"; -export const OH_SQLITE_SCHEMA_VERSION = 1 as const; +export const OH_SQLITE_SCHEMA_VERSION = 2 as const; export type OhSqliteMigration = Readonly<{ name: string; sql: string; version: number }>; @@ -113,6 +113,32 @@ CREATE VIRTUAL TABLE oh_search_fts USING fts5( CREATE INDEX oh_operations_space_sequence ON oh_operations(space_id, sequence); CREATE INDEX oh_records_space_kind ON oh_records(space_id, kind, record_key); CREATE INDEX oh_dependencies_dependency ON oh_dependencies(space_id, dependency_key); +`, + }), + Object.freeze({ + name: "0002_store_realms", + version: 2, + sql: ` +CREATE TABLE oh_space_bindings ( + space_id TEXT PRIMARY KEY REFERENCES oh_spaces(space_id), + realm_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_kind TEXT NOT NULL CHECK(profile_kind IN ('canonical', 'working')), + profile_sha256 TEXT NOT NULL CHECK(length(profile_sha256) = 64), + binding_sha256 TEXT NOT NULL UNIQUE CHECK(length(binding_sha256) = 64), + binding_json TEXT NOT NULL CHECK(json_valid(binding_json)), + created_at TEXT NOT NULL +) STRICT; + +CREATE TABLE oh_space_purges ( + space_id TEXT PRIMARY KEY, + binding_sha256 TEXT NOT NULL CHECK(length(binding_sha256) = 64), + prior_operation_sha256 TEXT CHECK(prior_operation_sha256 IS NULL OR length(prior_operation_sha256) = 64), + prior_sequence INTEGER NOT NULL CHECK(prior_sequence >= 0), + purged_at TEXT NOT NULL, + receipt_sha256 TEXT NOT NULL UNIQUE CHECK(length(receipt_sha256) = 64), + receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)) +) STRICT; `, }), ]); diff --git a/src/sqlite/port.test.ts b/src/sqlite/port.test.ts new file mode 100644 index 0000000..f06c1d7 --- /dev/null +++ b/src/sqlite/port.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { createKnowledgeGraphRecordV1 } from "../graph"; +import { + createOhStoreBindingV1, + OH_CANONICAL_STORE_PROFILE_V1, + OH_WORKING_STORE_PROFILE_V1, + OhProfileError, + OhPurgedSpaceError, +} from "../store"; +import { createOhSqliteStoreAuthorityV1 } from "./port"; +import { OhSqliteStore } from "./store"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +async function databasePath(): Promise { + const root = await mkdtemp(join(tmpdir(), "oh-port-test-")); + roots.push(root); + return join(root, "oh.sqlite"); +} + +const entity = (key: string, name: string, dependencies: readonly string[] = []) => + createKnowledgeGraphRecordV1({ dependencies, key, kind: "entity", v: 1, value: { name } }); + +describe("promise-based SQLite store port", () => { + test("reads exact historical heads and paginates a feed through a pinned head", async () => { + const authority = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:history", spaceId: "history" }); + const first = await authority.store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: entity("entity:a", "A"), v: 1 }], expectedHead: await authority.store.head(), + instant: "2026-08-29T12:00:00.000Z", operationId: "op_a" }); + await authority.store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: entity("entity:b", "B"), v: 1 }], expectedHead: await authority.store.head(), + instant: "2026-08-29T12:01:00.000Z", operationId: "op_b" }); + const third = await authority.store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: entity("entity:c", "C"), v: 1 }], expectedHead: await authority.store.head(), + instant: "2026-08-29T12:02:00.000Z", operationId: "op_c" }); + + const firstSnapshot = await authority.store.snapshot({ head: { + operationSha256: first.operationSha256, sequence: first.sequence } }); + expect(firstSnapshot.records.map(({ key }) => key)).toEqual(["entity:a"]); + const page = await authority.store.changesSince({ operationSha256: null, sequence: 0 }, { + limit: 2, through: { operationSha256: third.operationSha256, sequence: third.sequence }, + }); + expect(page.operations.map(({ operationId }) => operationId)).toEqual(["op_a", "op_b"]); + expect(page.hasMore).toBe(true); + const final = await authority.store.changesSince(page.to, { limit: 2, + through: { operationSha256: third.operationSha256, sequence: third.sequence } }); + expect(final.operations.map(({ operationId }) => operationId)).toEqual(["op_c"]); + expect(final.hasMore).toBe(false); + await authority.store.close(); + }); + + test("exports a verified closure from the exact requested head", async () => { + const authority = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:closure", spaceId: "closure" }); + const parent = entity("entity:parent", "Parent"); + const child = entity("entity:child", "Child", [parent.key]); + await authority.store.commit({ actorId: "agent.test", changes: [ + { kind: "put", record: child, v: 1 }, { kind: "put", record: parent, v: 1 }, + ], expectedHead: await authority.store.head(), operationId: "op_closure" }); + const closure = await authority.store.exportDependencyClosure({ roots: [child.key] }); + expect(closure.records.map(({ key }) => key)).toEqual([child.key, parent.key].sort()); + expect(closure.binding.bindingSha256).toBe(authority.store.binding.bindingSha256); + await authority.store.close(); + }); + + test("persists an exact realm binding and rejects a different profile on reopen", async () => { + const path = await databasePath(); + const canonical = createOhSqliteStoreAuthorityV1({ path, profile: OH_CANONICAL_STORE_PROFILE_V1, + realmId: "realm:canonical", spaceId: "same" }); + await canonical.store.close(); + expect(() => createOhSqliteStoreAuthorityV1({ path, profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:working", spaceId: "same" })).toThrow(OhProfileError); + }); + + test("keeps purge on host control, deletes all working payload rows, and leaves a receipt", async () => { + const path = await databasePath(); + const authority = createOhSqliteStoreAuthorityV1({ path, profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:purge", spaceId: "purge" }); + expect("purgeWorkingSpace" in authority.store).toBe(false); + await authority.store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: entity("entity:private", "Private"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_private" }); + const receipt = await authority.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" }); + expect(receipt.priorHead.sequence).toBe(1); + expect(await authority.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" })).toEqual(receipt); + + const database = new OhSqliteStore({ path, spaceId: "other" }); + for (const table of ["oh_operations", "oh_operation_records", "oh_records", "oh_dependencies", + "oh_search_documents", "oh_sync_outbox", "oh_sync_state", "oh_space_bindings", "oh_spaces"]) { + const where = table === "oh_operation_records" + ? "operation_sha256 IN (SELECT operation_sha256 FROM oh_operations WHERE space_id = 'purge')" + : "space_id = 'purge'"; + const count = database.database.query<{ count: number }, []>(`SELECT count(*) AS count FROM ${table} WHERE ${where}`).get()?.count; + expect(count, table).toBe(0); + } + expect(database.database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM oh_space_purges WHERE space_id = 'purge'", + ).get()?.count).toBe(1); + database.close(); + expect(() => new OhSqliteStore({ path, spaceId: "purge" })).toThrow(OhPurgedSpaceError); + }); + + test("never gives canonical profiles a destructive host capability", async () => { + const authority = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:canonical", spaceId: "canonical" }); + await expect(authority.host.purgeWorkingSpace({})).rejects.toThrow(OhProfileError); + await authority.store.close(); + }); + + test("refuses operation replication for a bound working profile", () => { + const store = new OhSqliteStore({ path: ":memory:", spaceId: "local-only" }); + store.bind(createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:local-only", spaceId: "local-only", v: 1 })); + expect(() => store.exportOperations()).toThrow(OhProfileError); + expect(() => store.importOperation({})).toThrow(OhProfileError); + expect(store.verifyReplay()).toMatchObject({ operations: 0, records: 0 }); + store.close(); + }); +}); diff --git a/src/sqlite/port.ts b/src/sqlite/port.ts new file mode 100644 index 0000000..69a319f --- /dev/null +++ b/src/sqlite/port.ts @@ -0,0 +1,120 @@ +import { canonicalJson } from "../canonical"; +import { + createOhStoreBindingV1, + OH_CANONICAL_STORE_PROFILE_V1, + OhProfileError, + parseOhStoreProfileV1, + type OhChangesPageV1, + type OhCommitInputV1, + type OhDependencyClosureV1, + type OhHeadRefV1, + type OhHeadV1, + type OhSnapshotV1, + type OhSpacePurgeReceiptV1, + type OhStoreAuthorityV1, + type OhStoreBindingV1, + type OhStoreHostControlV1, + type OhStoreProfileV1, + type OhStoreV1, + type OhStoreVerificationV1, +} from "../store"; +import type { OhOperationV1 } from "../operation"; +import type { OhSqliteDatabase } from "./driver"; +import { OhSqliteStore } from "./store"; + +export type OhSqliteStoreAuthorityOptionsV1 = Readonly<{ + database?: OhSqliteDatabase; + path?: string; + profile?: OhStoreProfileV1; + realmId?: string; + spaceId?: string; +}>; + +export class OhSqliteStorePortV1 implements OhStoreV1 { + readonly binding: OhStoreBindingV1; + readonly #authority: OhSqliteStore; + + constructor(authority: OhSqliteStore, binding: OhStoreBindingV1) { + const persisted = authority.bind(binding); + if (canonicalJson(persisted) !== canonicalJson(binding)) { + throw new OhProfileError("The SQLite authority returned a different store binding."); + } + this.#authority = authority; + this.binding = persisted; + } + + async head(): Promise { + return this.#authority.head(); + } + + async snapshot(options: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }> = {}): Promise { + return this.#authority.snapshotAtHead(options); + } + + async changesSince( + from: OhHeadRefV1, + options: Readonly<{ limit?: number; through?: OhHeadRefV1 }> = {}, + ): Promise { + return this.#authority.changesSince(from, options); + } + + async commit(input: OhCommitInputV1): Promise { + return this.#authority.commit(input); + } + + async exportDependencyClosure(input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): Promise { + return this.#authority.exportDependencyClosure({ binding: this.binding, ...input }); + } + + async verify(): Promise { + const verified = this.#authority.verifyReplay(); + return { head: verified.head, integrity: "verified", operations: verified.operations, + records: verified.records, v: 1 }; + } + + async close(): Promise { + this.#authority.close(); + } +} + +/** + * Binds a Bun SQLite authority to the promise-based store port. Retain the + * returned `host` object in trusted control-plane code; pass only `store` to + * ordinary consumers. + */ +export function createOhSqliteStoreAuthorityV1( + options: OhSqliteStoreAuthorityOptionsV1 = {}, +): OhStoreAuthorityV1 { + const profile = parseOhStoreProfileV1(options.profile ?? OH_CANONICAL_STORE_PROFILE_V1); + if (profile === null) throw new TypeError("Invalid SQLite store profile."); + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ profile, + realmId: options.realmId ?? `realm:${spaceId}`, spaceId, v: 1 }); + const authority = new OhSqliteStore({ + ...(options.database === undefined ? {} : { database: options.database }), + ...(options.path === undefined ? {} : { path: options.path }), + spaceId, + }); + const store = new OhSqliteStorePortV1(authority, binding); + let purge: OhSpacePurgeReceiptV1 | null = null; + const host: OhStoreHostControlV1 = Object.freeze({ + binding, + purgeWorkingSpace: async (input: Readonly<{ purgedAt?: string }>) => { + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("This host handle is not bound to a purgeable working profile."); + } + if (purge !== null) return purge; + purge = authority.purgeWorkingSpace(binding, input.purgedAt); + authority.close(); + return purge; + }, + }); + return Object.freeze({ host, store }); +} diff --git a/src/sqlite/store.ts b/src/sqlite/store.ts index 5f9dbbc..731cece 100644 --- a/src/sqlite/store.ts +++ b/src/sqlite/store.ts @@ -28,37 +28,42 @@ import { parseOhOperationV1, type OhOperationV1, } from "../operation"; -import { openOhSqliteDatabase, withImmediateTransaction, type OhSqliteDatabase } from "./driver"; +import { + createOhDependencyClosureV1, + createOhSpacePurgeReceiptV1, + emptyOhHeadV1, + OhConflictError, + OhDependencyError, + OhIntegrityError, + OhProfileError, + OhPurgedSpaceError, + parseOhHeadRefV1, + parseOhSpacePurgeReceiptV1, + parseOhStoreBindingV1, + replayOhOperationsV1, + type OhChangesPageV1, + type OhCommitInputV1, + type OhDependencyClosureV1, + type OhHeadRefV1, + type OhHeadV1, + type OhSnapshotV1, + type OhSpacePurgeReceiptV1, + type OhStoreBindingV1, +} from "../store"; +import { openOhSqliteDatabase, withImmediateTransaction, withReadTransaction, + type OhSqliteDatabase } from "./driver"; import { applyOhSqliteMigrations, OH_SQLITE_SCHEMA_VERSION } from "./migrations"; const EMPTY_RECORDS_SHA256 = canonicalSha256([]); -export class OhConflictError extends Error { - constructor(message: string) { super(message); this.name = "OhConflictError"; } -} -export class OhIntegrityError extends Error { - constructor(message: string) { super(message); this.name = "OhIntegrityError"; } -} -export class OhDependencyError extends Error { - constructor(message: string) { super(message); this.name = "OhDependencyError"; } -} - -export type OhHeadV1 = Readonly<{ - generation: number; - graphRevisionSha256: Sha256Hex | null; - operationSha256: Sha256Hex | null; - recordsSha256: Sha256Hex; - sequence: number; - v: 1; -}>; - -export type OhCommitInputV1 = Readonly<{ - actorId: string; - changes: readonly KnowledgeGraphChangeV1[]; - expectedHead: Pick; - instant?: string; - operationId: string; -}>; +export { + OhConflictError, + OhDependencyError, + OhIntegrityError, + OhProfileError, + OhPurgedSpaceError, +}; +export type { OhCommitInputV1, OhHeadV1 }; export type OhRecordListOptions = Readonly<{ kind?: KnowledgeGraphRecordKindV1; @@ -99,6 +104,8 @@ type CurrentRecordRow = { sequence: number; }; type OperationRow = { operation_json: string }; +type BindingRow = { binding_json: string }; +type PurgeRow = { receipt_json: string }; function parseHead(row: SpaceRow): OhHeadV1 { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); @@ -175,6 +182,13 @@ export class OhSqliteStore { if (this.#closed) throw new Error("The Oh store is closed."); } + #assertOperationReplication(): void { + const binding = this.binding(); + if (binding !== null && !binding.profile.capabilities.operationReplication) { + throw new OhProfileError("This bound store profile forbids operation replication."); + } + } + #registerContract(): void { const manifestJson = canonicalJson(OH_CONTRACT_MANIFEST_V1); this.database.query(`INSERT INTO oh_contracts(contract_id, contract_sha256, manifest_json, created_at) @@ -191,6 +205,18 @@ export class OhSqliteStore { ensureSpace(): OhHeadV1 { this.#assertOpen(); + const purged = this.database.query( + "SELECT receipt_json FROM oh_space_purges WHERE space_id = ?", + ).get(this.spaceId); + if (purged !== null) { + let value: unknown; + try { value = JSON.parse(purged.receipt_json); } catch { throw new OhIntegrityError("A purge receipt is not JSON."); } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { + throw new OhIntegrityError("A stored purge receipt is invalid."); + } + throw new OhPurgedSpaceError(receipt); + } const now = canonicalNow(); this.database.query(`INSERT INTO oh_spaces( space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, @@ -200,6 +226,44 @@ export class OhSqliteStore { return this.head(); } + bind(bindingValue: OhStoreBindingV1): OhStoreBindingV1 { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId) { + throw new OhProfileError("The store binding does not identify this space."); + } + const bindingJson = canonicalJson(binding); + this.database.query(`INSERT INTO oh_space_bindings( + space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run( + this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, + binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow(), + ); + const row = this.database.query( + "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + ).get(this.spaceId); + if (row === null || row.binding_json !== bindingJson) { + throw new OhProfileError("The space is already bound to a different realm or profile."); + } + return binding; + } + + binding(): OhStoreBindingV1 | null { + this.#assertOpen(); + const row = this.database.query( + "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + ).get(this.spaceId); + if (row === null) return null; + let value: unknown; + try { value = JSON.parse(row.binding_json); } catch { throw new OhIntegrityError("A store binding is not JSON."); } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json) { + throw new OhIntegrityError("A stored binding is invalid."); + } + return binding; + } + head(): OhHeadV1 { this.#assertOpen(); const row = this.database.query(`SELECT generation, graph_revision_sha256, @@ -354,6 +418,7 @@ export class OhSqliteStore { importOperation(value: unknown): Readonly<{ imported: boolean; operation: OhOperationV1 }> { this.#assertOpen(); + this.#assertOperationReplication(); const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); return withImmediateTransaction(this.database, () => { @@ -382,6 +447,7 @@ export class OhSqliteStore { exportOperations(afterSequence = 0, limit = 1000): readonly OhOperationV1[] { this.#assertOpen(); + this.#assertOperationReplication(); if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); const rows = this.database.query( @@ -394,6 +460,136 @@ export class OhSqliteStore { }); } + #headAt(reference: OhHeadRefV1): OhHeadV1 { + const parsed = parseOhHeadRefV1(reference); + if (parsed === null) throw new TypeError("Invalid Oh head reference."); + if (parsed.sequence === 0) return emptyOhHeadV1(); + const row = this.database.query( + "SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?", + ).get(this.spaceId, parsed.sequence); + if (row === null) throw new OhConflictError("The requested head is not present in this space."); + let value: unknown; + try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + if (operation.operationSha256 !== parsed.operationSha256) { + throw new OhConflictError("The requested sequence identifies a different operation head."); + } + return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256: operation.recordsSha256, + sequence: operation.sequence, v: 1 }; + } + + snapshotAtHead(options: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }> = {}): OhSnapshotV1 { + this.#assertOpen(); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 + || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + return withReadTransaction(this.database, () => { + const current = this.head(); + const target = options.head === undefined ? current : this.#headAt(options.head); + if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); + const rows = this.database.query( + "SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence", + ).all(this.spaceId, target.sequence); + const operations = rows.map((row) => { + let value: unknown; + try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } + if (canonicalJson(value) !== row.operation_json) throw new OhIntegrityError("A stored operation is not canonical JSON."); + const operation = parseOhOperationV1(value); + if (operation === null) throw new OhIntegrityError("A stored operation is invalid."); + return operation; + }); + const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); + if (snapshot.head.operationSha256 !== target.operationSha256 + || snapshot.head.recordsSha256 !== target.recordsSha256) { + throw new OhIntegrityError("Operation replay does not reproduce the requested head."); + } + return snapshot; + }); + } + + changesSince( + fromValue: OhHeadRefV1, + options: Readonly<{ limit?: number; through?: OhHeadRefV1 }> = {}, + ): OhChangesPageV1 { + this.#assertOpen(); + const from = parseOhHeadRefV1(fromValue); + if (from === null) throw new TypeError("Invalid change-feed cursor."); + const limit = normalizeLimit(options.limit, 100, 1000); + return withReadTransaction(this.database, () => { + const current = this.head(); + const fromHead = this.#headAt(from); + const through = options.through === undefined ? current : this.#headAt(options.through); + if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { + throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); + } + const rows = this.database.query( + `SELECT operation_json FROM oh_operations + WHERE space_id = ? AND sequence > ? AND sequence <= ? + ORDER BY sequence LIMIT ?`, + ).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); + const parsed = rows.map((row) => { + let value: unknown; + try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json) { + throw new OhIntegrityError("A stored operation is invalid."); + } + return operation; + }); + const hasMore = parsed.length > limit; + const operations = parsed.slice(0, limit); + const first = operations[0]; + if (first !== undefined && (first.sequence !== fromHead.sequence + 1 + || first.parentOperationSha256 !== fromHead.operationSha256)) { + throw new OhIntegrityError("The change feed does not extend its cursor."); + } + for (let index = 1; index < operations.length; index += 1) { + const prior = operations[index - 1] as OhOperationV1; + const operation = operations[index] as OhOperationV1; + if (operation.sequence !== prior.sequence + 1 + || operation.parentOperationSha256 !== prior.operationSha256) { + throw new OhIntegrityError("The change feed contains a gap or fork."); + } + } + const last = operations.at(-1); + const to = last === undefined + ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } + : { operationSha256: last.operationSha256, sequence: last.sequence }; + return { from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, + hasMore, operations, through, to, v: 1 }; + }); + } + + exportDependencyClosure(input: Readonly<{ + binding: OhStoreBindingV1; + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): OhDependencyClosureV1 { + const binding = parseOhStoreBindingV1(input.binding); + if (binding === null || binding.spaceId !== this.spaceId + || canonicalJson(this.binding()) !== canonicalJson(binding)) { + throw new OhProfileError("Dependency closure export requires the exact persisted store binding."); + } + if (!binding.profile.capabilities.dependencyClosureExport) { + throw new OhProfileError("This store profile does not permit dependency-closure export."); + } + const snapshot = this.snapshotAtHead({ ...(input.head === undefined ? {} : { head: input.head }), + ...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }) }); + return createOhDependencyClosureV1({ binding, + ...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }), + roots: input.roots, snapshot }); + } + get(key: string): KnowledgeGraphRecordV1 | null { this.#assertOpen(); const parsedKey = safeCode(key, 512); @@ -510,11 +706,9 @@ export class OhSqliteStore { const storedCount = this.database.query<{ count: number }, [string]>( "SELECT count(*) AS count FROM oh_operations WHERE space_id = ?", ).get(this.spaceId)?.count ?? 0; - const operations: readonly OhOperationV1[] = storedCount <= 1000 - ? this.exportOperations(0, 1000) - : this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence", - ).all(this.spaceId).map((row) => { + const operations: readonly OhOperationV1[] = this.database.query( + "SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence", + ).all(this.spaceId).map((row) => { let value: unknown; try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } if (canonicalJson(value) !== row.operation_json) throw new OhIntegrityError("A stored operation is not canonical JSON."); @@ -522,6 +716,7 @@ export class OhSqliteStore { if (parsed === null) throw new OhIntegrityError("A stored operation is invalid."); return parsed; }); + if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); } @@ -607,6 +802,43 @@ export class OhSqliteStore { return { manifest: OH_CONTRACT_MANIFEST_V1, sqliteSchemaVersion: OH_SQLITE_SCHEMA_VERSION }; } + /** Host control-plane primitive. Do not expose this method through agent tools. */ + purgeWorkingSpace(bindingValue: OhStoreBindingV1, purgedAt: string = canonicalNow()): OhSpacePurgeReceiptV1 { + this.#assertOpen(); + const binding = parseOhStoreBindingV1(bindingValue); + if (binding === null || binding.spaceId !== this.spaceId + || binding.profile.profileKind !== "working" + || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + return withImmediateTransaction(this.database, () => { + const row = this.database.query( + "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + ).get(this.spaceId); + if (row === null || row.binding_json !== canonicalJson(binding)) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); + this.database.query(`INSERT INTO oh_space_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(this.spaceId, binding.bindingSha256, + receipt.priorHead.operationSha256, receipt.priorHead.sequence, receipt.purgedAt, + receipt.receiptSha256, canonicalJson(receipt)); + this.database.query("DELETE FROM oh_search_fts WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_search_documents WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_dependencies WHERE space_id = ?").run(this.spaceId); + this.database.query(`DELETE FROM oh_operation_records WHERE operation_sha256 IN + (SELECT operation_sha256 FROM oh_operations WHERE space_id = ?)`).run(this.spaceId); + this.database.query("DELETE FROM oh_sync_outbox WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_sync_state WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_records WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); + this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + return receipt; + }); + } + close(): void { if (this.#closed) return; this.database.close(false); diff --git a/src/store.test.ts b/src/store.test.ts new file mode 100644 index 0000000..d17001c --- /dev/null +++ b/src/store.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; + +import { canonicalJson, canonicalSha256 } from "./canonical"; +import { OhRecordCodecRegistry } from "./contract"; +import { createKnowledgeGraphRecordV1 } from "./graph"; +import { createOhSqliteStoreAuthorityV1 } from "./sqlite/port"; +import { + createOhDependencyClosureV1, + createOhStoreBindingV1, + createOhStoreProfileV1, + emptyOhHeadV1, + OH_WORKING_STORE_PROFILE_V1, + OhProfileError, + OhSemanticBundleIngressV1, + parseOhDependencyClosureV1, + parseOhStoreBindingV1, + replayOhOperationsV1, + transitionOhSnapshotV1, + verifyOhDependencyClosureV1, +} from "./store"; + +describe("runtime-neutral Oh store contracts", () => { + test("binds a host-selected realm and application profile without changing V1 operations", () => { + const applicationProfileSha256 = canonicalSha256({ application: "fixture", v: 1 }); + const profile = createOhStoreProfileV1({ + applicationProfileSha256, + capabilities: OH_WORKING_STORE_PROFILE_V1.capabilities, + profileId: "fixture.working.v1", + profileKind: "working", + v: 1, + }); + const binding = createOhStoreBindingV1({ profile, realmId: "tenant:one/thread:two", + spaceId: "thread:two", v: 1 }); + expect(parseOhStoreBindingV1(binding)).toEqual(binding); + expect(binding.profile.applicationProfileSha256).toBe(applicationProfileSha256); + expect(binding.bindingSha256).toBe(canonicalSha256({ contractSha256: binding.contractSha256, + profile, realmId: binding.realmId, spaceId: binding.spaceId, v: 1 })); + expect(() => createOhStoreProfileV1({ applicationProfileSha256: null, + capabilities: { ...OH_WORKING_STORE_PROFILE_V1.capabilities, operationReplication: true }, + profileId: "unsafe.working.v1", profileKind: "working", v: 1 })).toThrow(OhProfileError); + }); + + test("exports only an exact dependency closure and detects tampering or smuggled records", () => { + const parent = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:parent", + kind: "entity", v: 1, value: { name: "Parent" } }); + const child = createKnowledgeGraphRecordV1({ dependencies: [parent.key], key: "entity:child", + kind: "entity", v: 1, value: { name: "Child" } }); + const unrelated = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:unrelated", + kind: "entity", v: 1, value: { name: "Unrelated" } }); + const binding = createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:test", spaceId: "space:test", v: 1 }); + const first = replayOhOperationsV1(binding.spaceId, []); + const snapshot = transitionOhSnapshotV1({ actorId: "agent.test", changes: [ + { kind: "put", record: child, v: 1 }, { kind: "put", record: parent, v: 1 }, + { kind: "put", record: unrelated, v: 1 }, + ], instant: "2026-08-29T12:00:00.000Z", operationId: "op_closure", + snapshot: first, spaceId: binding.spaceId }).snapshot; + const closure = createOhDependencyClosureV1({ binding, roots: [child.key], + snapshot }); + expect(closure.records.map(({ key }) => key)).toEqual([child.key, parent.key].sort()); + expect(parseOhDependencyClosureV1(closure)).toEqual(closure); + expect(verifyOhDependencyClosureV1(closure)).toEqual({ closure, ok: true }); + expect(parseOhDependencyClosureV1({ ...closure, records: [...closure.records, unrelated] })).toBeNull(); + expect(parseOhDependencyClosureV1({ ...closure, + closureSha256: "a".repeat(64) })).toBeNull(); + expect(first.records).toEqual([]); + }); + + test("requires a sealed explicit codec for every semantic put and commits one atomic bundle", async () => { + const authority = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:semantic", spaceId: "semantic" }); + const codecs = new OhRecordCodecRegistry().register({ kind: "entity", parse: (value) => { + if (typeof value !== "object" || value === null || Array.isArray(value) + || Object.keys(value).length !== 1 || typeof (value as { name?: unknown }).name !== "string") return null; + return { name: (value as { name: string }).name.normalize("NFC") }; + } }); + const ingress = new OhSemanticBundleIngressV1(authority.store, codecs); + expect(codecs.sealed).toBe(true); + expect(() => codecs.register({ kind: "statement", parse: () => ({}) })).toThrow("sealed"); + const head = await authority.store.head(); + const operation = await ingress.commit({ actorId: "agent.test", expectedHead: { + generation: head.generation, operationSha256: head.operationSha256 }, instant: "2026-08-29T12:00:00.000Z", + operationId: "op_semantic", puts: [{ dependencies: [], key: "entity:ada", kind: "entity", + v: 1, value: { name: "Ada" } }], tombstones: [], v: 1 }); + expect(operation.changes).toHaveLength(1); + await expect(ingress.commit({ actorId: "agent.test", expectedHead: { + generation: 1, operationSha256: operation.operationSha256 }, instant: null, + operationId: "op_unregistered", puts: [{ dependencies: [], key: "statement:no-codec", + kind: "statement", v: 1, value: {} }], tombstones: [], v: 1 })) + .rejects.toThrow("codec rejected"); + expect((await authority.store.head()).sequence).toBe(1); + await expect(ingress.commit({ actorId: "agent.test", expectedHead: { + generation: 1, operationSha256: operation.operationSha256 }, instant: null, + operationId: "op_missing_dependency", puts: [{ dependencies: ["entity:missing"], + key: "entity:child", kind: "entity", v: 1, value: { name: "Child" } }], + tombstones: [], v: 1 })).rejects.toThrow("Missing dependency"); + expect((await authority.store.head()).sequence).toBe(1); + expect(canonicalJson((await authority.store.snapshot()).records[0]?.value)).toBe('{"name":"Ada"}'); + await authority.store.close(); + }); +}); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..d133c81 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,673 @@ +import { + canonicalJson, + canonicalSha256, + hasExactKeys, + isPlainRecord, + parseCanonicalInstantV1, + parseSha256Hex, + safeCode, + type JsonValue, + type Sha256Hex, +} from "./canonical"; +import { + OH_CONTRACT_MANIFEST_V1, + type OhRecordCodecRegistry, +} from "./contract"; +import { + canonicalKnowledgeGraphChangesV1, + createKnowledgeGraphRecordV1, + graphRevisionSha256V1, + knowledgeGraphRecordRefV1, + OH_GRAPH_LIMITS_V1, + OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + parseKnowledgeGraphRecordV1, + type KnowledgeGraphChangeV1, + type KnowledgeGraphRecordKindV1, + type KnowledgeGraphRecordV1, +} from "./graph"; +import { + createOhOperationV1, + parseOhOperationV1, + type OhOperationV1, +} from "./operation"; + +export class OhConflictError extends Error { + constructor(message: string) { super(message); this.name = "OhConflictError"; } +} + +export class OhIntegrityError extends Error { + constructor(message: string) { super(message); this.name = "OhIntegrityError"; } +} + +export class OhDependencyError extends Error { + constructor(message: string) { super(message); this.name = "OhDependencyError"; } +} + +export class OhProfileError extends Error { + constructor(message: string) { super(message); this.name = "OhProfileError"; } +} + +export type OhHeadV1 = Readonly<{ + generation: number; + graphRevisionSha256: Sha256Hex | null; + operationSha256: Sha256Hex | null; + recordsSha256: Sha256Hex; + sequence: number; + v: 1; +}>; + +export type OhHeadRefV1 = Pick; + +export type OhCommitInputV1 = Readonly<{ + actorId: string; + changes: readonly KnowledgeGraphChangeV1[]; + expectedHead: Pick; + instant?: string; + operationId: string; +}>; + +export type OhSnapshotV1 = Readonly<{ + head: OhHeadV1; + records: readonly KnowledgeGraphRecordV1[]; + v: 1; +}>; + +export type OhChangesPageV1 = Readonly<{ + from: OhHeadRefV1; + hasMore: boolean; + operations: readonly OhOperationV1[]; + through: OhHeadV1; + to: OhHeadRefV1; + v: 1; +}>; + +export type OhStoreVerificationV1 = Readonly<{ + head: OhHeadV1; + integrity: "verified"; + operations: number; + records: number; + v: 1; +}>; + +export type OhStoreCapabilitiesV1 = Readonly<{ + changesSince: true; + dependencyClosureExport: true; + exactSnapshots: true; + operationReplication: boolean; + semanticBundleCommit: true; + v: 1; + wholeSpacePurge: boolean; +}>; + +export type OhStoreProfileV1 = Readonly<{ + applicationProfileSha256: Sha256Hex | null; + capabilities: OhStoreCapabilitiesV1; + profileId: string; + profileKind: "canonical" | "working"; + profileSha256: Sha256Hex; + v: 1; +}>; + +export type OhStoreBindingV1 = Readonly<{ + bindingSha256: Sha256Hex; + contractSha256: Sha256Hex; + profile: OhStoreProfileV1; + realmId: string; + spaceId: string; + v: 1; +}>; + +export const OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false, + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1, +}); + +export const OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true, + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1, +}); + +export type OhDependencyClosureV1 = Readonly<{ + binding: OhStoreBindingV1; + closureSha256: Sha256Hex; + head: OhHeadV1; + records: readonly KnowledgeGraphRecordV1[]; + roots: readonly string[]; + v: 1; +}>; + +export const OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8_192, + roots: 1_024, +}); + +export type OhSpacePurgeReceiptV1 = Readonly<{ + bindingSha256: Sha256Hex; + priorHead: OhHeadV1; + purgedAt: string; + receiptSha256: Sha256Hex; + spaceId: string; + v: 1; +}>; + +export class OhPurgedSpaceError extends Error { + readonly receipt: OhSpacePurgeReceiptV1; + + constructor(receipt: OhSpacePurgeReceiptV1) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} + +export interface OhStoreV1 { + readonly binding: OhStoreBindingV1; + changesSince( + from: OhHeadRefV1, + options?: Readonly<{ limit?: number; through?: OhHeadRefV1 }>, + ): Promise; + close(): Promise; + commit(input: OhCommitInputV1): Promise; + exportDependencyClosure(input: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + roots: readonly string[]; + }>): Promise; + head(): Promise; + snapshot(options?: Readonly<{ + head?: OhHeadRefV1; + maximumRecords?: number; + }>): Promise; + verify(): Promise; +} + +/** Kept separate so an agent-facing store object never carries deletion authority. */ +export interface OhStoreHostControlV1 { + readonly binding: OhStoreBindingV1; + purgeWorkingSpace(input: Readonly<{ purgedAt?: string }>): Promise; +} + +export type OhStoreAuthorityV1 = Readonly<{ + host: OhStoreHostControlV1; + store: OhStoreV1; +}>; + +const EMPTY_RECORDS_SHA256 = canonicalSha256([]); + +export function emptyOhHeadV1(): OhHeadV1 { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1, + }; +} + +export function parseOhHeadV1(value: unknown): OhHeadV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["generation", "graphRevisionSha256", + "operationSha256", "recordsSha256", "sequence", "v"]) || value.v !== 1) return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null + ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null + ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && (value.generation as number) >= 0 + ? value.generation as number : null; + const sequence = Number.isSafeInteger(value.sequence) && (value.sequence as number) >= 0 + ? value.sequence as number : null; + return generation !== null && sequence !== 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; +} + +export function parseOhHeadRefV1(value: unknown): OhHeadRefV1 | null { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && (value.sequence as number) >= 0 + ? value.sequence as number : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) + && ((sequence === 0) === (operationSha256 === null)) + ? { operationSha256, sequence } : null; +} + +function parseCapabilities(value: unknown): OhStoreCapabilitiesV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["changesSince", "dependencyClosureExport", + "exactSnapshots", "operationReplication", "semanticBundleCommit", "v", "wholeSpacePurge"]) + || value.changesSince !== true || value.dependencyClosureExport !== true + || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" + || value.semanticBundleCommit !== true || value.v !== 1 + || typeof value.wholeSpacePurge !== "boolean") return null; + return { changesSince: true, dependencyClosureExport: true, exactSnapshots: true, + operationReplication: value.operationReplication, semanticBundleCommit: true, v: 1, + wholeSpacePurge: value.wholeSpacePurge }; +} + +type OhStoreProfileInputV1 = Omit; + +export function createOhStoreProfileV1(input: OhStoreProfileInputV1): OhStoreProfileV1 { + if (!isPlainRecord(input) || !hasExactKeys(input, ["applicationProfileSha256", "capabilities", + "profileId", "profileKind", "v"]) || input.v !== 1) throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null + ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null + || (input.applicationProfileSha256 !== null && applicationProfileSha256 === null) + || (input.profileKind !== "canonical" && input.profileKind !== "working")) { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" + && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { applicationProfileSha256, capabilities: Object.freeze(capabilities), profileId, + profileKind: input.profileKind, v: 1 as const }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} + +export function parseOhStoreProfileV1(value: unknown): OhStoreProfileV1 | null { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input as unknown as OhStoreProfileInputV1); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { return null; } +} + +export function createOhStoreBindingV1(input: Readonly<{ + profile: OhStoreProfileV1; + realmId: string; + spaceId: string; + v: 1; +}>): OhStoreBindingV1 { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, realmId, spaceId, v: 1 as const }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} + +export function parseOhStoreBindingV1(value: unknown): OhStoreBindingV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["bindingSha256", "contractSha256", + "profile", "realmId", "spaceId", "v"]) || value.v !== 1 + || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) return null; + const created = createOhStoreBindingV1({ profile, realmId: value.realmId as string, + spaceId: value.spaceId as string, v: 1 }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { return null; } +} + +function sortedRecords(records: Iterable): readonly KnowledgeGraphRecordV1[] { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} + +function verifyDependencies(records: ReadonlyMap): void { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} + +export function replayOhOperationsV1( + spaceId: string, + values: readonly OhOperationV1[], + maximumRecords: number = OH_GRAPH_LIMITS_V1.recordsPerSnapshot, +): OhSnapshotV1 { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 + || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map(); + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId + || operation.sequence !== head.sequence + 1 + || operation.parentOperationSha256 !== head.operationSha256) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + for (const change of operation.changes) { + if (change.kind === "put") records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ changes: operation.changes, + operationId: operation.operationId, parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, revision: operation.sequence }); + if (recordsSha256 !== operation.recordsSha256 + || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { generation: operation.sequence, graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256, + sequence: operation.sequence, v: 1 }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} + +export function transitionOhSnapshotV1(input: Readonly<{ + actorId: string; + changes: readonly KnowledgeGraphChangeV1[]; + instant: string; + operationId: string; + snapshot: OhSnapshotV1; + spaceId: string; +}>): Readonly<{ operation: OhOperationV1; snapshot: OhSnapshotV1 }> { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null + || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map(); + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ changes, operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, recordsSha256, + revision: head.sequence + 1 }); + const operation = createOhOperationV1({ actorId, changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, graphRevisionSha256, instant, + operationId, parentOperationSha256: head.operationSha256, + recordsSha256, sequence: head.sequence + 1, spaceId, v: 1 }); + const nextHead: OhHeadV1 = { generation: operation.sequence, graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256, + sequence: operation.sequence, v: 1 }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} + +function normalizeRoots(values: readonly string[]): readonly string[] { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots as string[]].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} + +function closureRecords( + available: ReadonlyMap, + roots: readonly string[], + maximumRecords: number, +): readonly KnowledgeGraphRecordV1[] { + const selected = new Map(); + const pending = [...roots]; + while (pending.length > 0) { + const key = pending.pop() as string; + if (selected.has(key)) continue; + const record = available.get(key); + if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selected.set(key, record); + if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} + +export function createOhDependencyClosureV1(input: Readonly<{ + binding: OhStoreBindingV1; + maximumRecords?: number; + roots: readonly string[]; + snapshot: OhSnapshotV1; +}>): OhDependencyClosureV1 { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) + || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map(); + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 as const }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} + +export function parseOhDependencyClosureV1(value: unknown): OhDependencyClosureV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["binding", "closureSha256", "head", + "records", "roots", "v"]) || value.v !== 1 || !Array.isArray(value.records) + || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) return null; + const records = new Map(); + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots as string[]); + if (canonicalJson(roots) !== canonicalJson(value.roots)) return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) return null; + const payload = { binding, head, records: exact, roots, v: 1 as const }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 + && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes + ? Object.freeze(parsed) : null; + } catch { return null; } +} + +export function verifyOhDependencyClosureV1(value: unknown): + | Readonly<{ closure: OhDependencyClosureV1; ok: true }> + | Readonly<{ ok: false; reason: "invalid-closure" }> { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} + +export function createOhSpacePurgeReceiptV1(input: Readonly<{ + binding: OhStoreBindingV1; + priorHead: OhHeadV1; + purgedAt: string; +}>): OhSpacePurgeReceiptV1 { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null + || binding.profile.profileKind !== "working" + || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { bindingSha256: binding.bindingSha256, priorHead, purgedAt, + spaceId: binding.spaceId, v: 1 as const }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} + +export function parseOhSpacePurgeReceiptV1(value: unknown): OhSpacePurgeReceiptV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["bindingSha256", "priorHead", "purgedAt", + "receiptSha256", "spaceId", "v"]) || value.v !== 1) return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null + || receiptSha256 === null || spaceId === null) return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 as const }; + return canonicalSha256(payload) === receiptSha256 + ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +export type OhSemanticBundleV1 = Readonly<{ + actorId: string; + expectedHead: Pick; + instant: string | null; + operationId: string; + puts: readonly Readonly<{ + dependencies: readonly string[]; + key: string; + kind: KnowledgeGraphRecordKindV1; + v: 1; + value: unknown; + }>[]; + tombstones: readonly Readonly<{ + key: string; + priorSha256: Sha256Hex; + v: 1; + }>[]; + v: 1; +}>; + +/** A strict model-facing ingress: every put must have a registered codec. */ +export class OhSemanticBundleIngressV1 { + readonly #codecs: OhRecordCodecRegistry; + readonly #store: OhStoreV1; + + constructor(store: OhStoreV1, codecs: OhRecordCodecRegistry) { + this.#store = store; + this.#codecs = codecs.seal(); + } + + async commit(value: unknown): Promise { + if (!isPlainRecord(value) || !hasExactKeys(value, ["actorId", "expectedHead", "instant", + "operationId", "puts", "tombstones", "v"]) || value.v !== 1 + || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) + || value.puts.length + value.tombstones.length < 1 + || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) + || !hasExactKeys(expected, ["generation", "operationSha256"]) + || !Number.isSafeInteger(expected.generation) || (expected.generation as number) < 0 + || (expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null) + || (((expected.generation as number) === 0) !== (expected.operationSha256 === null)) + || (value.instant !== null && instant === null)) throw new TypeError("Invalid semantic bundle identity."); + const changes: KnowledgeGraphChangeV1[] = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) + || item.v !== 1 || !Array.isArray(item.dependencies) + || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind as KnowledgeGraphRecordKindV1, item.value); + if (parsed === null) throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ dependencies: item.dependencies as string[], + key: item.key as string, kind: item.kind as KnowledgeGraphRecordKindV1, v: 1, value: parsed }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ actorId, changes: canonical, + expectedHead: { generation: expected.generation as number, + operationSha256: expected.operationSha256 as Sha256Hex | null }, + ...(typeof instant === "string" ? { instant } : {}), operationId }); + } +} diff --git a/tests/node-portable.mjs b/tests/node-portable.mjs new file mode 100644 index 0000000..3041095 --- /dev/null +++ b/tests/node-portable.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; + +// Exercise the published package subpaths, not internal build paths. This also +// catches an export that accidentally makes Bun-only modules reachable. +const store = await import("@hraness/oh/store"); +const libsql = await import("@hraness/oh/libsql"); + +assert.equal(typeof store.createOhStoreBindingV1, "function"); +assert.equal(typeof store.OhSemanticBundleIngressV1, "function"); +assert.equal(typeof libsql.createOhLibSqlStoreAuthorityV1, "function"); +assert.equal(store.OH_WORKING_STORE_PROFILE_V1.profileKind, "working"); +assert.equal(store.OH_WORKING_STORE_PROFILE_V1.capabilities.operationReplication, false); +assert.equal(store.OH_WORKING_STORE_PROFILE_V1.capabilities.wholeSpacePurge, true); diff --git a/tests/public-surface.test.ts b/tests/public-surface.test.ts index 38157fe..80b150b 100644 --- a/tests/public-surface.test.ts +++ b/tests/public-surface.test.ts @@ -27,6 +27,7 @@ const markdownFiles = [ "spec/v1/schema-evolution.md", "spec/v1/graph.md", "spec/v1/storage.md", + "spec/v1/store.md", "spec/v1/sync.md", "spec/v1/embedding.md", "spec/v1/projection.md", @@ -153,7 +154,7 @@ describe("public identity and documentation", () => { expect(packageJson.license).toBe("MIT"); expect(packageJson.private).toBe(false); expect(packageJson.packageManager).toBe("bun@1.3.14"); - expect(packageJson.engines).toEqual({ bun: ">=1.3.14" }); + expect(packageJson.engines).toEqual({ bun: ">=1.3.14", node: ">=24" }); expect(packageJson.repository).toEqual({ type: "git", url: "git+https://github.com/hraness/oh.git" }); expect(packageJson.bugs).toEqual({ url: "https://github.com/hraness/oh/issues" }); expect(skill).toMatch(/^---\nname: oh\ndescription: .+\n---\n/u); @@ -278,11 +279,13 @@ describe("versioned public contract", () => { expect(Object.keys(exports).sort()).toEqual([ ".", "./experimental/projection-suss", + "./libsql", "./package.json", "./projection", "./sdk", "./semantic", "./sqlite", + "./store", "./sync", ]); @@ -346,6 +349,7 @@ describe("repository policy", () => { const scripts = packageJson.scripts as Record; const siteScripts = sitePackageJson.scripts as Record; expect(scripts.check).toContain("bun run test"); + expect(scripts.check).toContain("bun run test:node"); expect(scripts.test).toBe("bun test ./src ./tests ./site/tests/source.test.ts"); expect(scripts.test).not.toContain("runtime.test.ts"); expect(siteScripts.postbuild).toBe("bun test ./tests/runtime.test.ts"); From 87390ebc8508844057218c66ab3abc1095bf959c Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 29 Aug 2026 20:19:37 -0400 Subject: [PATCH 3/3] feat: add composite agent memory --- README.md | 70 +- SECURITY.md | 21 + dist/cli.d.ts | 2 +- dist/cli.js | 324 +- dist/index.js | 25 +- dist/libsql.d.ts | 10 + dist/libsql.d.ts.map | 2 +- dist/libsql.js | 1103 +++++- dist/memory.d.ts | 231 ++ dist/memory.d.ts.map | 1 + dist/memory.js | 3007 +++++++++++++++++ dist/projection-public.d.ts | 5 + dist/projection-public.d.ts.map | 2 +- dist/projection-public.js | 1240 +++---- dist/projection-suss.d.ts.map | 2 +- dist/projection-suss.js | 1249 +++---- dist/projection.d.ts | 35 +- dist/projection.d.ts.map | 2 +- dist/sdk.js | 322 +- dist/sqlite/index.js | 322 +- dist/sqlite/store.d.ts.map | 2 +- dist/store.d.ts | 19 +- dist/store.d.ts.map | 2 +- dist/store.js | 45 +- package.json | 11 +- scripts/projection-node.test.mjs | 4 + site/app/spec/page.tsx | 24 +- site/package.json | 2 +- site/public/spec/README.md | 7 + site/public/spec/manifest.json | 16 +- site/public/spec/v1/memory.md | 114 + .../spec/v1/projection-identity.schema.json | 58 + .../spec/v1/projection-query.schema.json | 60 + .../spec/v1/projection-result.schema.json | 452 +++ .../spec/v1/projection-rule-pack.schema.json | 182 + site/public/spec/v1/projection.md | 84 +- site/public/spec/v1/store.md | 31 +- skills/oh/SKILL.md | 20 +- spec/README.md | 7 + spec/manifest.json | 16 +- spec/v1/memory.md | 114 + spec/v1/projection-identity.schema.json | 58 + spec/v1/projection-query.schema.json | 60 + spec/v1/projection-result.schema.json | 452 +++ spec/v1/projection-rule-pack.schema.json | 182 + spec/v1/projection.md | 84 +- spec/v1/store.md | 31 +- src/cli.ts | 2 +- src/libsql.test.ts | 280 +- src/libsql.ts | 1029 +++++- src/memory.test.ts | 376 +++ src/memory.ts | 957 ++++++ src/projection-public.ts | 2 + src/projection-suss.ts | 12 +- src/projection.test.ts | 140 +- src/projection.ts | 447 ++- src/sqlite/store.test.ts | 136 +- src/sqlite/store.ts | 335 +- src/store.test.ts | 20 + src/store.ts | 32 +- tests/node-portable-types.ts | 17 + tests/node-portable.mjs | 58 + tests/public-surface.test.ts | 53 +- tsconfig.node-portable.json | 7 + 64 files changed, 11651 insertions(+), 2364 deletions(-) create mode 100644 dist/memory.d.ts create mode 100644 dist/memory.d.ts.map create mode 100644 dist/memory.js create mode 100644 site/public/spec/v1/memory.md create mode 100644 site/public/spec/v1/projection-identity.schema.json create mode 100644 site/public/spec/v1/projection-query.schema.json create mode 100644 site/public/spec/v1/projection-result.schema.json create mode 100644 site/public/spec/v1/projection-rule-pack.schema.json create mode 100644 spec/v1/memory.md create mode 100644 spec/v1/projection-identity.schema.json create mode 100644 spec/v1/projection-query.schema.json create mode 100644 spec/v1/projection-result.schema.json create mode 100644 spec/v1/projection-rule-pack.schema.json create mode 100644 src/memory.test.ts create mode 100644 src/memory.ts create mode 100644 tests/node-portable-types.ts create mode 100644 tsconfig.node-portable.json diff --git a/README.md b/README.md index da88853..1a61270 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ indexes derived and replaceable. - **Derive without silently asserting.** Positive recursive rules run against one exact graph head and fact-pack digest. Their tuples and bounded proofs are deterministic, disposable output rather than accepted graph records. +- **Remember without conflating authority.** An experimental facade composes a + purgeable working authority with one pinned canonical head while preserving + lane, conflict, record, and proof provenance. ## Install and first run @@ -37,7 +40,7 @@ direct libSQL authority also support Node 24 serverless runtimes. Install the current immutable release directly from GitHub: ```sh -bun add --global github:hraness/oh#v0.1.1 +bun add --global github:hraness/oh#v0.2.0 oh --help ``` @@ -97,7 +100,7 @@ For a project dependency, pin the same immutable release in `package.json`: ```json { "dependencies": { - "@hraness/oh": "github:hraness/oh#v0.1.1" + "@hraness/oh": "github:hraness/oh#v0.2.0" } } ``` @@ -141,7 +144,9 @@ promise interface, `@hraness/oh/libsql` for a direct Node 24 or serverless authority, `@hraness/oh/sqlite` for the local Bun store, `@hraness/oh/sdk` for the local `Oh` facade, `@hraness/oh/sync` for transport seams, `@hraness/oh/projection` for recursive derived views, and -`@hraness/oh/semantic` for the optional local embedding backend. +`@hraness/oh/semantic` for the optional local embedding backend. The +`@hraness/oh/experimental/memory` subpath composes host-bound working and +canonical stores behind a smaller agent-facing surface. ## Open a scoped working store @@ -189,6 +194,63 @@ only on `authority.host`; do not expose that object or raw database credentials through a model tool. Read the [store-port specification](spec/v1/store.md) for exact snapshot, change-feed, codec ingress, closure, and purge behavior. +## Compose working and canonical memory + +The experimental memory facade uses the same Oh kernel twice, not a separate +memory database model. Trusted host code supplies two distinct physical store +handles, their expected binding digests, one exact canonical head, sealed +working codecs, digest-identified fact extractors, and a closed registry of +named projection programs: + +```ts +import { createOhMemoryAgentV1 } from "@hraness/oh/experimental/memory"; + +const memory = await createOhMemoryAgentV1({ + actorId: "research.memory-agent", + canonical: { + authorityId: "project-reviewed", + expectedBindingSha256: canonical.store.binding.bindingSha256, + expectedHead: await canonical.store.head(), + store: canonical.store, + }, + nominationRoutes: [{ + destinationPurpose: "kb.review", + nominationId: "knowledge-review", + }], + programs: [{ + programId: "project.dependencies", + purpose: "answer.research", + query, + rulePack, + }], + working: { + authorityId: "thread-working", + codecs, + expectedBindingSha256: working.store.binding.bindingSha256, + store: working.store, + }, +}); + +const result = await memory.query({ + programId: "project.dependencies", + v: 1, +}); +``` + +The returned object has only `remember`, `query`, `explain`, and `nominate`. +The host fixes the working actor, each program purpose, and every nomination +destination before exposing those methods. `remember` accepts an idempotency +request plus semantic changes and returns a locator-free working-lane receipt; +it does not accept caller-supplied actor or time claims. The object cannot +select a store, install a rule, write canonical knowledge, sync, or purge. +Query identity binds both exact physical lanes and all projection policy; +conflicting same-key records remain visible. Explanation requires a bounded, +short-lived opaque capability bound to the exact result. Nomination chooses +only a host-registered route and creates a verified working dependency-closure +proposal; it never promotes it. Read the +[experimental memory specification](spec/v1/memory.md) for the complete +authority and lifecycle boundary. + ## Derive an exact projection The projection subpath is pure TypeScript and runs in Node 24 serverless @@ -389,7 +451,7 @@ keep remote sync explicit. You can also give an agent this prompt: ```text -Install hraness/oh and its Oh Agent Skill from the immutable v0.1.1 tag at +Install hraness/oh and its Oh Agent Skill from the immutable v0.2.0 tag at https://github.com/hraness/oh. Verify the CLI with `oh --help` and `oh version`. Do not create or modify an Oh database until I name its path and ask you to. ``` diff --git a/SECURITY.md b/SECURITY.md index c564c17..3e0aba2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,6 +44,27 @@ problem that has already been fixed there. - Store profiles are host control metadata. V1 operation digests do not attest to that profile, and callers with raw database or filesystem access remain outside the profile API boundary. +- The experimental memory facade is an in-process capability boundary, not a + tenant authenticator or sandbox. The host must bind its canonical and working + store handles, enforce tenant and session authorization before every call, + keep raw credentials and purge handles out of model tools, and never reuse a + facade across authorization domains. +- Memory fact extractors are trusted host code. They receive complete record + values, and their declared digest identifies policy but does not sandbox, + authenticate, or attest to a JavaScript function. Review extractors as code, + make them deterministic, and give them no ambient authority they do not need. + Invocation count, returned fact count, and retained bytes are bounded, but a + synchronous extractor's own execution time and temporary allocation are + outside the evaluator's resource guarantees. +- Explanation tokens are short-lived, process-local bearer capabilities bound + to one exact result. They can explain multiple rows until expiry or eviction; + the process keeps a bounded aggregate evidence cache. Do not log, persist, + share across tenants, or treat them as evidence that a caller may read either + underlying store. +- Composite query rows remain derived even when all visible premises are + canonical. A prepared nomination is content-addressed transport for a later + destination-owned review; it is not approval, synchronization, or permission + to mutate canonical knowledge. - Oh does not redact record values. Do not write secrets or sensitive research into a space unless the database, filesystem, backups, and sync destination have the required protection. diff --git a/dist/cli.d.ts b/dist/cli.d.ts index 0e51c80..dd13ae4 100644 --- a/dist/cli.d.ts +++ b/dist/cli.d.ts @@ -1,4 +1,4 @@ #!/usr/bin/env bun -export declare const OH_PACKAGE_VERSION: "0.1.1"; +export declare const OH_PACKAGE_VERSION: "0.2.0"; export declare function runOhCli(arguments_: readonly string[]): Promise; //# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js index 4952436..5193eba 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -1605,12 +1605,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -1734,9 +1736,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -1744,6 +1747,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -1817,6 +1823,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -2167,6 +2187,51 @@ function applyOhSqliteMigrations(database) { // src/sqlite/store.ts var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); +var OPERATION_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +var BINDING_COLUMNS = `space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json`; +var PURGE_COLUMNS = `space_id, binding_sha256, prior_operation_sha256, prior_sequence, + purged_at, receipt_sha256, receipt_json`; +function parseStoredOperationRow(row, expected = {}) { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json || row.operation_sha256 !== operation.operationSha256 || row.space_id !== operation.spaceId || row.sequence !== operation.sequence || row.operation_id !== operation.operationId || row.parent_operation_sha256 !== operation.parentOperationSha256 || row.graph_revision_sha256 !== operation.graphRevisionSha256 || row.records_sha256 !== operation.recordsSha256 || row.instant !== operation.instant || expected.spaceId !== undefined && operation.spaceId !== expected.spaceId || expected.operationId !== undefined && operation.operationId !== expected.operationId || expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256) { + throw new OhIntegrityError("Stored operation columns do not match their canonical envelope."); + } + return operation; +} +function parseStoredBindingRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json || binding.spaceId !== expectedSpaceId || row.space_id !== binding.spaceId || row.realm_id !== binding.realmId || row.profile_id !== binding.profile.profileId || row.profile_kind !== binding.profile.profileKind || row.profile_sha256 !== binding.profile.profileSha256 || row.binding_sha256 !== binding.bindingSha256) { + throw new OhIntegrityError("Stored binding columns do not match their canonical envelope."); + } + return binding; +} +function parseStoredPurgeRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== row.receipt_json || receipt.spaceId !== expectedSpaceId || row.space_id !== receipt.spaceId || row.binding_sha256 !== receipt.bindingSha256 || row.prior_operation_sha256 !== receipt.priorHead.operationSha256 || row.prior_sequence !== receipt.priorHead.sequence || row.purged_at !== receipt.purgedAt || row.receipt_sha256 !== receipt.receiptSha256) { + throw new OhIntegrityError("Stored purge columns do not match their canonical receipt."); + } + return receipt; +} function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -2271,26 +2336,18 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); - const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); - if (purged !== null) { - let value; - try { - value = JSON.parse(purged.receipt_json); - } catch { - throw new OhIntegrityError("A purge receipt is not JSON."); - } - const receipt = parseOhSpacePurgeReceiptV1(value); - if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { - throw new OhIntegrityError("A stored purge receipt is invalid."); + return withImmediateTransaction(this.database, () => { + const purged = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (purged !== null) { + throw new OhPurgedSpaceError(parseStoredPurgeRow(purged, this.spaceId)); } - throw new OhPurgedSpaceError(receipt); - } - const now = canonicalNow(); - this.database.query(`INSERT INTO oh_spaces( - space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, - records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); - return this.head(); + const now = canonicalNow(); + this.database.query(`INSERT INTO oh_spaces( + space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, + records_sha256, sequence, created_at, updated_at + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); + return this.head(); + }); } bind(bindingValue) { this.#assertOpen(); @@ -2303,28 +2360,21 @@ class OhSqliteStore { space_id, realm_id, profile_id, profile_kind, profile_sha256, binding_sha256, binding_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== bindingJson) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) + throw new OhIntegrityError("The persisted store binding disappeared."); + const persisted = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persisted) !== bindingJson) { throw new OhProfileError("The space is already bound to a different realm or profile."); } return binding; } binding() { this.#assertOpen(); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); if (row === null) return null; - let value; - try { - value = JSON.parse(row.binding_json); - } catch { - throw new OhIntegrityError("A store binding is not JSON."); - } - const binding = parseOhStoreBindingV1(value); - if (binding === null || canonicalJson(binding) !== row.binding_json) { - throw new OhIntegrityError("A stored binding is invalid."); - } - return binding; + return parseStoredBindingRow(row, this.spaceId); } head() { this.#assertOpen(); @@ -2386,6 +2436,48 @@ class OhSqliteStore { }); return { graphRevisionSha256, records, recordsSha256 }; } + #assertCurrentHeadAuthority(head) { + const summary = this.database.query(`SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_operations WHERE space_id = ?`).get(this.spaceId); + if (summary === null || summary.count !== head.sequence || head.sequence === 0 && (summary.minimum !== null || summary.maximum !== null) || head.sequence > 0 && (summary.minimum !== 1 || summary.maximum !== head.sequence)) { + throw new OhIntegrityError("The operation history does not exactly cover the current space head."); + } + if (head.sequence === 0) + return; + if (head.operationSha256 === null) + throw new OhIntegrityError("A nonempty space head has no operation digest."); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, head.sequence); + if (row === null) + throw new OhIntegrityError("The current space head operation is missing."); + const operation = parseStoredOperationRow(row, { + spaceId: this.spaceId, + operationSha256: head.operationSha256 + }); + if (operation.sequence !== head.sequence || operation.graphRevisionSha256 !== head.graphRevisionSha256 || operation.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The current space head differs from its canonical operation."); + } + } + #assertOperationReachable(operation, head) { + if (operation.sequence < 1 || operation.sequence > head.sequence) { + throw new OhIntegrityError("A stored idempotent operation is not reachable from the current head."); + } + const rows = this.database.query(`SELECT operation_sha256, parent_operation_sha256, sequence + FROM oh_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, operation.sequence, head.sequence); + if (rows.length !== head.sequence - operation.sequence + 1) { + throw new OhIntegrityError("A stored idempotent operation has an incomplete path to the current head."); + } + let priorSha256 = operation.parentOperationSha256; + for (let index = 0;index < rows.length; index += 1) { + const row = rows[index]; + if (row === undefined || row.sequence !== operation.sequence + index || row.parent_operation_sha256 !== priorSha256 || index === 0 && row.operation_sha256 !== operation.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation is not on the current authority chain."); + } + priorSha256 = row.operation_sha256; + } + if (priorSha256 !== head.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation does not reach the current head digest."); + } + } #persist(operation) { this.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, @@ -2447,17 +2539,17 @@ class OhSqliteStore { if (changes.length === 0 || changes.length > 8192) throw new TypeError("A commit needs 1 through 8192 changes."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND operation_id = ?").get(this.spaceId, operationId); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND operation_id = ?`).get(this.spaceId, operationId); if (duplicate !== null) { - const existing = parseOhOperationV1(JSON.parse(duplicate.operation_json)); - if (existing === null) - throw new OhIntegrityError("The stored idempotent operation is invalid."); + const existing = parseStoredOperationRow(duplicate, { operationId, spaceId: this.spaceId }); + this.#assertOperationReachable(existing, head); if (existing.actorId !== actorId || canonicalJson(existing.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return existing; } - const head = this.head(); if (head.generation !== input.expectedHead.generation || head.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current space head."); } @@ -2486,14 +2578,20 @@ class OhSqliteStore { if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE operation_sha256 = ?").get(operation.operationSha256); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE operation_sha256 = ?`).get(operation.operationSha256); if (duplicate !== null) { - if (canonicalJson(JSON.parse(duplicate.operation_json)) !== canonicalJson(operation)) { + const existing = parseStoredOperationRow(duplicate, { + operationSha256: operation.operationSha256, + spaceId: this.spaceId + }); + this.#assertOperationReachable(existing, head); + if (canonicalJson(existing) !== canonicalJson(operation)) { throw new OhIntegrityError("An operation digest is bound to different bytes."); } return { imported: false, operation }; } - const head = this.head(); if (operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { throw new OhConflictError("The imported operation does not extend the local head."); } @@ -2511,13 +2609,9 @@ class OhSqliteStore { if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(this.spaceId, afterSequence, boundedLimit); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null || canonicalJson(operation) !== row.operation_json) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`).all(this.spaceId, afterSequence, boundedLimit); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } #headAt(reference) { const parsed = parseOhHeadRefV1(reference); @@ -2525,20 +2619,11 @@ class OhSqliteStore { throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); - const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, parsed.sequence); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseStoredOperationRow(row, { spaceId: this.spaceId }); + if (operation.spaceId !== this.spaceId || operation.sequence !== parsed.sequence || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { @@ -2561,21 +2646,9 @@ class OhSqliteStore { const target = options.head === undefined ? current : this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); - const operations = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const operation = parseOhOperationV1(value); - if (operation === null) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, target.sequence); + const operations = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { throw new OhIntegrityError("Operation replay does not reproduce the requested head."); @@ -2596,34 +2669,23 @@ class OhSqliteStore { if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); } - const rows = this.database.query(`SELECT operation_json FROM oh_operations + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); - const parsed = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - return operation; - }); + const parsed = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); + if (parsed.length > limit + 1) + throw new OhIntegrityError("The change feed exceeded its requested page bound."); const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); - const first = operations[0]; - if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { - throw new OhIntegrityError("The change feed does not extend its cursor."); - } - for (let index = 1;index < operations.length; index += 1) { - const prior = operations[index - 1]; - const operation = operations[index]; + let prior = fromHead; + for (const operation of parsed) { if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The change feed contains a gap or fork."); } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + if (!hasMore && (prior.sequence !== through.sequence || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The change feed does not reach its pinned through head."); } const last = operations.at(-1); const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; @@ -2699,13 +2761,9 @@ class OhSqliteStore { } log(limit = 50) { this.#assertOpen(); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence DESC LIMIT ?").all(this.spaceId, normalizeLimit(limit)); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null) - throw new OhIntegrityError("The stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? ORDER BY sequence DESC LIMIT ?`).all(this.spaceId, normalizeLimit(limit)); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } searchKeyword(query, limit = 20) { this.#assertOpen(); @@ -2761,20 +2819,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const parsed = parseOhOperationV1(value); - if (parsed === null) - throw new OhIntegrityError("A stored operation is invalid."); - return parsed; - }); + const operations = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? ORDER BY sequence`).all(this.spaceId).map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); @@ -2782,6 +2827,7 @@ class OhSqliteStore { #verifyOperations(operations) { const records = new Map; const materializedBy = new Map; + const operationIds = new Set; let head = { generation: 0, graphRevisionSha256: null, @@ -2791,8 +2837,9 @@ class OhSqliteStore { v: 1 }; for (const operation of operations) { - if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) + if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) throw new OhIntegrityError("Operation replay chain is broken."); + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") { records.set(change.record.key, change.record); @@ -2876,8 +2923,12 @@ class OhSqliteStore { throw new OhProfileError("Whole-space purge requires a bound working profile."); } return withImmediateTransaction(this.database, () => { - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== canonicalJson(binding)) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const persistedBinding = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persistedBinding) !== canonicalJson(binding)) { throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); } const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); @@ -2895,6 +2946,33 @@ class OhSqliteStore { this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + const directTables = [ + "oh_spaces", + "oh_space_bindings", + "oh_operations", + "oh_records", + "oh_dependencies", + "oh_search_documents", + "oh_sync_outbox", + "oh_sync_state" + ]; + for (const table of directTables) { + const count = this.database.query(`SELECT count(*) AS count FROM ${table} WHERE space_id = ?`).get(this.spaceId)?.count; + if (count !== 0) + throw new OhIntegrityError(`Space purge left rows in ${table}.`); + } + const operationRecords = this.database.query(`SELECT count(*) AS count + FROM oh_operation_records AS materialized JOIN oh_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ?`).get(this.spaceId)?.count; + const searchRows = this.database.query("SELECT count(*) AS count FROM oh_search_fts WHERE space_id = ?").get(this.spaceId)?.count; + if (operationRecords !== 0 || searchRows !== 0) { + throw new OhIntegrityError("Space purge left derived private payload rows."); + } + const receiptRow = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (receiptRow === null || canonicalJson(parseStoredPurgeRow(receiptRow, this.spaceId)) !== canonicalJson(receipt)) { + throw new OhIntegrityError("The stored purge receipt differs from the requested purge."); + } return receipt; }); } @@ -2994,7 +3072,7 @@ class Oh { // src/cli.ts import { readFile } from "fs/promises"; -var OH_PACKAGE_VERSION = "0.1.1"; +var OH_PACKAGE_VERSION = "0.2.0"; var KNOWN_OPTIONS = new Set([ "actor", "after", diff --git a/dist/index.js b/dist/index.js index 12ef854..3ddef8e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1552,12 +1552,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -1681,9 +1683,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -1691,6 +1694,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -1764,6 +1770,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -1867,6 +1887,7 @@ class OhSemanticBundleIngressV1 { } export { verifyOhDependencyClosureV1, + verifyOhDependencyClosureAgainstV1, verifyKnowledgeValueV1, verifyKnowledgeSchemaEvolutionV1, utf8ByteLength, diff --git a/dist/libsql.d.ts b/dist/libsql.d.ts index 0ff11a5..d98458f 100644 --- a/dist/libsql.d.ts +++ b/dist/libsql.d.ts @@ -21,6 +21,16 @@ export type OhLibSqlStoreAuthorityOptionsV1 = Readonly<{ realmId?: string; spaceId?: string; }>; +export declare const OH_LIBSQL_STORE_LIMITS_V1: Readonly<{ + changesPerCommit: 64; + changeFeedLimit: 7; + dependenciesPerCommit: 512; + historyBytes: number; + historyOperations: 16384; + operationBytes: number; + providerResponseBytes: 9000000; + snapshotComponentBytes: number; +}>; /** One-time schema operation for a client authorized to create authority tables. */ export declare function bootstrapOhLibSqlAuthorityV1(client: OhLibSqlClientV1): Promise maximumBytes) { + throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit"); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new OhValidationError("invalid-json", "$", "is not valid JSON"); + } + if (canonicalJson(value) !== text) { + throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical"); + } + return value; +} +function utf8ByteLength(value) { + return Buffer.byteLength(value, "utf8"); +} function sha256Hex(value) { return createHash("sha256").update(value).digest("hex"); } @@ -317,7 +335,6 @@ class OhRecordCodecRegistry { return this.#sealed; } } - // src/operation.ts var OH_OPERATION_MAX_BYTES_V1 = 64 * 1024 * 1024; function parsePayload(value) { @@ -617,12 +634,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -746,9 +765,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -756,6 +776,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -829,6 +852,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -932,9 +969,45 @@ class OhSemanticBundleIngressV1 { } // src/libsql.ts +var OH_LIBSQL_STORE_LIMITS_V1 = Object.freeze({ + changesPerCommit: 64, + changeFeedLimit: 7, + dependenciesPerCommit: 512, + historyBytes: 4 * 1024 * 1024, + historyOperations: 16384, + operationBytes: 512 * 1024, + providerResponseBytes: 9000000, + snapshotComponentBytes: 6 * 1024 * 1024 +}); var AUTHORITY_SCHEMA_NAME = "oh.libsql-authority.v1"; var AUTHORITY_SCHEMA_VERSION = 1; var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); +var PURGE_ROW_SELECT = `SELECT space_id, binding_sha256, prior_operation_sha256, + prior_sequence, purged_at, receipt_sha256, receipt_json + FROM oh_authority_purges WHERE space_id = ?`; +var BINDING_ROW_SELECT = `SELECT space_id, realm_id, profile_id, profile_kind, + profile_sha256, binding_sha256, binding_json FROM oh_authority_bindings WHERE space_id = ?`; +var OPERATION_ROW_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +var OPERATION_RESPONSE_BYTES = `2 * length(CAST(operation.operation_json AS BLOB)) + + 2 * (length(operation.operation_sha256) + length(operation.space_id) + + length(operation.operation_id) + coalesce(length(operation.parent_operation_sha256), 0) + + length(operation.graph_revision_sha256) + length(operation.records_sha256) + + length(operation.instant)) + 512`; +var RECORD_RESPONSE_BYTES = `2 * length(CAST(record.record_json AS BLOB)) + + 2 * (length(record.record_key) + length(record.kind) + length(record.record_sha256) + + length(record.operation_sha256)) + 384`; +var DEPENDENCY_RESPONSE_BYTES = `2 * (length(dependency.record_key) + + length(dependency.dependency_key)) + 192`; +var OPERATION_RECORD_RESPONSE_BYTES = `2 * (length(materialized.space_id) + + length(materialized.operation_sha256) + length(materialized.record_key) + + length(materialized.change_kind) + length(materialized.record_sha256)) + 320`; +var AUTHORITY_SCHEMA_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS oh_authority_schemas ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + schema_sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL +) STRICT`; var AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ `CREATE TABLE IF NOT EXISTS oh_authority_contracts ( contract_id TEXT PRIMARY KEY, @@ -967,6 +1040,7 @@ var AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ UNIQUE(space_id, operation_id) ) STRICT`, `CREATE TABLE IF NOT EXISTS oh_authority_operation_records ( + space_id TEXT NOT NULL, operation_sha256 TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal >= 0), record_key TEXT NOT NULL, @@ -1015,15 +1089,64 @@ var AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ ) STRICT`, "CREATE INDEX IF NOT EXISTS oh_authority_operations_space_sequence ON oh_authority_operations(space_id, sequence)", "CREATE INDEX IF NOT EXISTS oh_authority_records_space_kind ON oh_authority_records(space_id, kind, record_key)", - "CREATE INDEX IF NOT EXISTS oh_authority_dependencies_dependency ON oh_authority_dependencies(space_id, dependency_key)" + "CREATE INDEX IF NOT EXISTS oh_authority_dependencies_dependency ON oh_authority_dependencies(space_id, dependency_key)", + `CREATE TRIGGER IF NOT EXISTS oh_authority_operations_no_update + BEFORE UPDATE ON oh_authority_operations + BEGIN SELECT RAISE(ABORT, 'Oh authority operations are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operations_guard_delete + BEFORE DELETE ON oh_authority_operations + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = OLD.space_id) + BEGIN SELECT RAISE(ABORT, 'Oh authority operations require a purge receipt'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_no_update + BEFORE UPDATE ON oh_authority_operation_records + BEGIN SELECT RAISE(ABORT, 'Oh authority operation records are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_guard_insert + BEFORE INSERT ON oh_authority_operation_records + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_operations + WHERE operation_sha256 = NEW.operation_sha256 AND space_id = NEW.space_id) + BEGIN SELECT RAISE(ABORT, 'Oh authority operation record has no owning operation'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_guard_delete + BEFORE DELETE ON oh_authority_operation_records + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_operations AS operation + JOIN oh_authority_purges AS purge ON purge.space_id = operation.space_id + WHERE operation.operation_sha256 = OLD.operation_sha256) + BEGIN SELECT RAISE(ABORT, 'Oh authority operation records require a purge receipt'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_purges_immutable_update + BEFORE UPDATE ON oh_authority_purges + BEGIN SELECT RAISE(ABORT, 'Oh authority purge receipts are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_purges_immutable_delete + BEFORE DELETE ON oh_authority_purges + BEGIN SELECT RAISE(ABORT, 'Oh authority purge receipts are immutable'); END` ]); -var AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_STATEMENTS); +function normalizedSchemaSql(sql) { + return sql.replace(/\bIF\s+NOT\s+EXISTS\b/giu, "").replace(/\s+/gu, " ").trim(); +} +function expectedSchemaObject(statement) { + const match = /^CREATE\s+(TABLE|INDEX|TRIGGER)(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-z0-9_]+)/iu.exec(statement.trim()); + if (match === null) + throw new Error("Invalid compiled authority schema statement."); + const declaredType = match[1]?.toLowerCase(); + const type = declaredType === "index" ? "index" : declaredType === "trigger" ? "trigger" : "table"; + const name = match[2]; + const tableMatch = type === "index" || type === "trigger" ? /\bON\s+([a-z0-9_]+)/iu.exec(statement) : null; + const tableName = type === "table" ? name : tableMatch?.[1]; + if (tableName === undefined) + throw new Error("Invalid compiled authority index statement."); + return { name, sql: normalizedSchemaSql(statement), tableName, type }; +} +var AUTHORITY_SCHEMA_OBJECTS = Object.freeze([AUTHORITY_SCHEMA_TABLE_STATEMENT, ...AUTHORITY_SCHEMA_STATEMENTS].map(expectedSchemaObject).sort((left, right) => canonicalJson([left.type, left.name]).localeCompare(canonicalJson([right.type, right.name])))); +var AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_OBJECTS); function rowValue(row, key, index) { return Array.isArray(row) ? row[index] : row[key]; } function integer(value) { - const parsed = typeof value === "bigint" ? Number(value) : Number(value); - return Number.isSafeInteger(parsed) ? parsed : null; + if (typeof value === "number") + return Number.isSafeInteger(value) ? value : null; + if (typeof value === "bigint") { + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; + } + return null; } function normalizeLimit(value, fallback = 100, maximum = 1000) { const limit = value ?? fallback; @@ -1047,6 +1170,48 @@ function parseOperationJson(value) { } return operation; } +function parseOperationRow(row, expected = {}) { + const operation = parseOperationJson(rowValue(row, "operation_json", 7)); + if (rowValue(row, "operation_sha256", 0) !== operation.operationSha256 || rowValue(row, "space_id", 1) !== operation.spaceId || integer(rowValue(row, "sequence", 2)) !== operation.sequence || rowValue(row, "operation_id", 3) !== operation.operationId || rowValue(row, "parent_operation_sha256", 4) !== operation.parentOperationSha256 || rowValue(row, "graph_revision_sha256", 5) !== operation.graphRevisionSha256 || rowValue(row, "records_sha256", 6) !== operation.recordsSha256 || rowValue(row, "instant", 8) !== operation.instant || expected.spaceId !== undefined && operation.spaceId !== expected.spaceId || expected.operationId !== undefined && operation.operationId !== expected.operationId || expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256) { + throw new OhIntegrityError("Remote operation columns do not match their canonical envelope."); + } + return operation; +} +function parseBindingRow(row, expectedSpaceId) { + const json = rowValue(row, "binding_json", 6); + if (typeof json !== "string") + throw new OhIntegrityError("A remote store binding is not JSON text."); + let value; + try { + value = JSON.parse(json); + } catch { + throw new OhIntegrityError("A remote store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== json || binding.spaceId !== expectedSpaceId || rowValue(row, "space_id", 0) !== binding.spaceId || rowValue(row, "realm_id", 1) !== binding.realmId || rowValue(row, "profile_id", 2) !== binding.profile.profileId || rowValue(row, "profile_kind", 3) !== binding.profile.profileKind || rowValue(row, "profile_sha256", 4) !== binding.profile.profileSha256 || rowValue(row, "binding_sha256", 5) !== binding.bindingSha256) { + throw new OhIntegrityError("Remote binding columns do not match their canonical envelope."); + } + return binding; +} +function parsePurgeReceiptRow(row, expectedSpaceId, expectedBindingSha256) { + const json = rowValue(row, "receipt_json", 6); + if (typeof json !== "string") + throw new OhIntegrityError("A remote purge receipt is invalid."); + let value; + try { + value = JSON.parse(json); + } catch { + throw new OhIntegrityError("A remote purge receipt is invalid."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== json) { + throw new OhIntegrityError("A remote purge receipt is invalid."); + } + if (receipt.spaceId !== expectedSpaceId || expectedBindingSha256 !== undefined && receipt.bindingSha256 !== expectedBindingSha256 || rowValue(row, "space_id", 0) !== receipt.spaceId || rowValue(row, "binding_sha256", 1) !== receipt.bindingSha256 || rowValue(row, "prior_operation_sha256", 2) !== receipt.priorHead.operationSha256 || integer(rowValue(row, "prior_sequence", 3)) !== receipt.priorHead.sequence || rowValue(row, "purged_at", 4) !== receipt.purgedAt || rowValue(row, "receipt_sha256", 5) !== receipt.receiptSha256) { + throw new OhIntegrityError("Remote purge columns do not match their canonical receipt."); + } + return receipt; +} function parseHeadRow(row) { const generation = integer(rowValue(row, "generation", 0)); const graphValue = rowValue(row, "graph_revision_sha256", 1); @@ -1063,6 +1228,25 @@ function parseHeadRow(row) { async function queryOne(client, statement) { return (await client.execute(statement)).rows[0] ?? null; } +async function verifyAuthoritySchemaObjects(client) { + const rows = (await client.execute({ sql: `SELECT type, name, tbl_name, sql FROM sqlite_schema + WHERE sql IS NOT NULL AND (name = 'oh_authority_schemas' OR name GLOB 'oh_authority_*' + OR tbl_name GLOB 'oh_authority_*') + ORDER BY type, name` })).rows; + const actual = rows.map((row) => { + const type = rowValue(row, "type", 0); + const name = rowValue(row, "name", 1); + const tableName = rowValue(row, "tbl_name", 2); + const sql = rowValue(row, "sql", 3); + if (type !== "table" && type !== "index" && type !== "trigger" || typeof name !== "string" || typeof tableName !== "string" || typeof sql !== "string") { + throw new OhIntegrityError("The installed libSQL authority has an invalid schema object."); + } + return { name, sql: normalizedSchemaSql(sql), tableName, type }; + }); + if (canonicalJson(actual) !== canonicalJson(AUTHORITY_SCHEMA_OBJECTS)) { + throw new OhIntegrityError("The installed libSQL authority objects differ from this runtime."); + } +} async function verifyAuthoritySchema(client) { const installed = await queryOne(client, { sql: `SELECT name, schema_sha256 FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); @@ -1074,20 +1258,20 @@ async function verifyAuthoritySchema(client) { if (contract === null || rowValue(contract, "contract_sha256", 0) !== OH_CONTRACT_MANIFEST_V1.contractSha256 || rowValue(contract, "manifest_json", 1) !== canonicalJson(OH_CONTRACT_MANIFEST_V1)) { throw new OhIntegrityError("The remote authority contract differs from this runtime."); } + await verifyAuthoritySchemaObjects(client); } async function bootstrapOhLibSqlAuthorityV1(client) { - await client.execute(`CREATE TABLE IF NOT EXISTS oh_authority_schemas ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - schema_sha256 TEXT NOT NULL, - applied_at TEXT NOT NULL - ) STRICT`); - const applied = await queryOne(client, { sql: `SELECT name, schema_sha256 - FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); - if (applied !== null && (rowValue(applied, "name", 0) !== AUTHORITY_SCHEMA_NAME || rowValue(applied, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256)) { - throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + const existingObjects = (await client.execute({ sql: `SELECT name FROM sqlite_schema + WHERE sql IS NOT NULL AND (name = 'oh_authority_schemas' OR name GLOB 'oh_authority_*' + OR tbl_name GLOB 'oh_authority_*')` })).rows; + if (existingObjects.length > 0) { + if (!existingObjects.some((row) => rowValue(row, "name", 0) === "oh_authority_schemas")) { + throw new OhIntegrityError("Refusing to bootstrap over preexisting Oh authority objects."); + } + await verifyAuthoritySchema(client); + return { schemaSha256: AUTHORITY_SCHEMA_SHA256, schemaVersion: 1, v: 1 }; } - const setup = AUTHORITY_SCHEMA_STATEMENTS.map((sql) => ({ sql })); + const setup = [AUTHORITY_SCHEMA_TABLE_STATEMENT, ...AUTHORITY_SCHEMA_STATEMENTS].map((sql) => ({ sql })); setup.push({ sql: `INSERT INTO oh_authority_schemas(version, name, schema_sha256, applied_at) VALUES (?, ?, ?, ?) ON CONFLICT(version) DO NOTHING`, @@ -1105,47 +1289,81 @@ async function bootstrapOhLibSqlAuthorityV1(client) { } async function initializeSpace(client, binding) { const purged = await queryOne(client, { - sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + sql: PURGE_ROW_SELECT, args: [binding.spaceId] }); - if (purged !== null) { - const json = rowValue(purged, "receipt_json", 0); - if (typeof json !== "string") - throw new OhIntegrityError("A remote purge receipt is invalid."); - const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); - if (receipt === null || canonicalJson(receipt) !== json) - throw new OhIntegrityError("A remote purge receipt is invalid."); - throw new OhPurgedSpaceError(receipt); - } + if (purged !== null) + throw new OhPurgedSpaceError(parsePurgeReceiptRow(purged, binding.spaceId, binding.bindingSha256)); const now = canonicalNow(); - await client.batch([ - { - sql: `INSERT INTO oh_authority_spaces(space_id, contract_id, generation, + try { + await client.batch([ + { + sql: `INSERT INTO oh_authority_spaces(space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at) - VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`, - args: [binding.spaceId, OH_CONTRACT_MANIFEST_V1.contractId, EMPTY_RECORDS_SHA2562, now, now] - }, - { - sql: `INSERT INTO oh_authority_bindings(space_id, realm_id, profile_id, profile_kind, + SELECT ?, ?, 0, NULL, NULL, ?, 0, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, + args: [ + binding.spaceId, + OH_CONTRACT_MANIFEST_V1.contractId, + EMPTY_RECORDS_SHA2562, + now, + now, + binding.spaceId + ] + }, + { + sql: `INSERT INTO oh_authority_bindings(space_id, realm_id, profile_id, profile_kind, profile_sha256, binding_sha256, binding_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`, - args: [ - binding.spaceId, - binding.realmId, - binding.profile.profileId, - binding.profile.profileKind, - binding.profile.profileSha256, - binding.bindingSha256, - canonicalJson(binding), - now - ] + SELECT ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + AND EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, + args: [ + binding.spaceId, + binding.realmId, + binding.profile.profileId, + binding.profile.profileKind, + binding.profile.profileSha256, + binding.bindingSha256, + canonicalJson(binding), + now, + binding.spaceId, + binding.spaceId + ] + }, + { + sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + OR NOT EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR NOT EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ? AND binding_sha256 = ?)`, + args: [binding.spaceId, binding.spaceId, binding.spaceId, binding.bindingSha256] + } + ], "write"); + } catch (error) { + const raced = await queryOne(client, { + sql: PURGE_ROW_SELECT, + args: [binding.spaceId] + }); + if (raced !== null) + throw new OhPurgedSpaceError(parsePurgeReceiptRow(raced, binding.spaceId, binding.bindingSha256)); + const persisted2 = await queryOne(client, { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }); + if (persisted2 !== null && canonicalJson(parseBindingRow(persisted2, binding.spaceId)) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is already bound to a different realm or profile."); } - ], "write"); - const persisted = await queryOne(client, { - sql: "SELECT binding_json FROM oh_authority_bindings WHERE space_id = ?", - args: [binding.spaceId] - }); - if (persisted === null || rowValue(persisted, "binding_json", 0) !== canonicalJson(binding)) { + throw error; + } + const persisted = await queryOne(client, { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }); + if (persisted === null) { + const raced = await queryOne(client, { + sql: PURGE_ROW_SELECT, + args: [binding.spaceId] + }); + if (raced !== null) + throw new OhPurgedSpaceError(parsePurgeReceiptRow(raced, binding.spaceId, binding.bindingSha256)); + throw new OhIntegrityError("The remote space has no persisted binding after initialization."); + } + if (canonicalJson(parseBindingRow(persisted, binding.spaceId)) !== canonicalJson(binding)) { throw new OhProfileError("The remote space is already bound to a different realm or profile."); } } @@ -1186,18 +1404,12 @@ class OhLibSqlStoreV1 { } async#readPurge() { const row = await queryOne(this.#client, { - sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }); if (row === null) return null; - const json = rowValue(row, "receipt_json", 0); - if (typeof json !== "string") - throw new OhIntegrityError("A remote purge receipt is invalid."); - const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); - if (receipt === null || canonicalJson(receipt) !== json) - throw new OhIntegrityError("A remote purge receipt is invalid."); - return receipt; + return parsePurgeReceiptRow(row, this.binding.spaceId, this.binding.bindingSha256); } async#headAt(reference) { const parsed = parseOhHeadRefV1(reference); @@ -1205,12 +1417,12 @@ class OhLibSqlStoreV1 { throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); - const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + const row = await queryOne(this.#client, { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, parsed.sequence] }); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - const operation = parseOperationJson(rowValue(row, "operation_json", 0)); - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (operation.spaceId !== this.binding.spaceId || operation.sequence !== parsed.sequence || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { @@ -1222,16 +1434,241 @@ class OhLibSqlStoreV1 { v: 1 }; } + async#currentMaterializedSnapshot(expectedHead, maximumRecords) { + const provenancePredicate = `operation.space_id = ? AND (operation.operation_sha256 IS ? + OR operation.operation_sha256 IN (SELECT record.operation_sha256 + FROM oh_authority_records AS record WHERE record.space_id = ?))`; + const results = await this.#client.batch([ + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { + sql: `SELECT + (SELECT count(*) FROM (SELECT DISTINCT operation.operation_sha256 + FROM oh_authority_operations AS operation WHERE ${provenancePredicate})) AS provenance_count, + (SELECT coalesce(sum(bytes), 0) FROM (SELECT DISTINCT operation.operation_sha256, + ${OPERATION_RESPONSE_BYTES} AS bytes FROM oh_authority_operations AS operation + WHERE ${provenancePredicate})) AS provenance_bytes, + (SELECT count(*) FROM oh_authority_records WHERE space_id = ?) AS record_count, + (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) FROM oh_authority_records AS record + WHERE record.space_id = ?) AS record_bytes, + (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ?) AS dependency_bytes`, + args: [ + this.binding.spaceId, + expectedHead.operationSha256, + this.binding.spaceId, + this.binding.spaceId, + expectedHead.operationSha256, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId + ] + }, + { sql: `SELECT record_key, kind, record_sha256, record_json, operation_sha256, sequence + FROM oh_authority_records AS record WHERE record.space_id = ? + AND (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) <= ? ORDER BY record_key`, args: [ + this.binding.spaceId, + this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + ] }, + { + sql: `SELECT record_key, dependency_key FROM oh_authority_dependencies + AS dependency WHERE dependency.space_id = ? + AND (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency WHERE dependency.space_id = ?) <= ? + ORDER BY record_key, dependency_key`, + args: [this.binding.spaceId, this.binding.spaceId, OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] + }, + { + sql: `SELECT ${OPERATION_ROW_COLUMNS} + FROM oh_authority_operations AS operation + WHERE ${provenancePredicate} + AND (SELECT coalesce(sum(bytes), 0) FROM (SELECT DISTINCT candidate.operation_sha256, + ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND (candidate.operation_sha256 IS ? + OR candidate.operation_sha256 IN (SELECT record.operation_sha256 + FROM oh_authority_records AS record WHERE record.space_id = ?)))) <= ? + ORDER BY operation.sequence`, + args: [ + this.binding.spaceId, + expectedHead.operationSha256, + this.binding.spaceId, + this.binding.spaceId, + expectedHead.operationSha256, + this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + ] + }, + { sql: `SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] } + ], "read"); + if (results.length !== 6) + throw new OhIntegrityError("The remote authority returned an incomplete snapshot batch."); + const [headResult, sizeResult, recordResult, dependencyResult, provenanceResult, historyResult] = results; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purge = await this.#readPurge(); + if (purge !== null) { + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote Oh space disappeared while reading its snapshot."); + } + const head = parseHeadRow(headRow); + if (canonicalJson(head) !== canonicalJson(expectedHead)) { + throw new OhConflictError("The remote space head changed while reading its current snapshot."); + } + const size = sizeResult.rows[0]; + const provenanceOperations = size === undefined ? null : integer(rowValue(size, "provenance_count", 0)); + const provenanceBytes = size === undefined ? null : integer(rowValue(size, "provenance_bytes", 1)); + const recordCount = size === undefined ? null : integer(rowValue(size, "record_count", 2)); + const recordBytes = size === undefined ? null : integer(rowValue(size, "record_bytes", 3)); + const dependencyBytes = size === undefined ? null : integer(rowValue(size, "dependency_bytes", 4)); + if (provenanceOperations === null || provenanceBytes === null || recordCount === null || recordBytes === null || dependencyBytes === null || provenanceOperations > OH_LIBSQL_STORE_LIMITS_V1.historyOperations || provenanceBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes || recordBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes || dependencyBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes) { + throw new RangeError("The current libSQL materialization exceeds its provider-safe response bounds."); + } + if (recordResult.rows.length !== recordCount || provenanceResult.rows.length !== provenanceOperations) { + throw new OhIntegrityError("The provider-safe snapshot queries omitted bounded authority rows."); + } + const history = historyResult.rows[0]; + const operationCount = history === undefined ? null : integer(rowValue(history, "count", 0)); + const minimumValue = history === undefined ? undefined : rowValue(history, "minimum", 1); + const maximumValue = history === undefined ? undefined : rowValue(history, "maximum", 2); + const minimumSequence = history === undefined ? null : integer(rowValue(history, "minimum", 1)); + const maximumSequence = history === undefined ? null : integer(rowValue(history, "maximum", 2)); + if (operationCount !== head.sequence || head.sequence === 0 && (minimumValue !== null || maximumValue !== null) || head.sequence > 0 && (minimumSequence !== 1 || maximumSequence !== head.sequence)) { + throw new OhIntegrityError("The remote operation history does not exactly cover its current head."); + } + if (recordResult.rows.length > maximumRecords) { + throw new RangeError("The remote graph exceeds the requested record snapshot bound."); + } + const provenanceBySha256 = new Map; + for (const row of provenanceResult.rows) { + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (provenanceBySha256.has(operation.operationSha256)) { + throw new OhIntegrityError("A current materialization provenance operation is invalid."); + } + provenanceBySha256.set(operation.operationSha256, operation); + } + if (provenanceBySha256.size !== provenanceOperations || head.operationSha256 !== null && !provenanceBySha256.has(head.operationSha256)) { + throw new OhIntegrityError("The current materialization omitted required provenance operations."); + } + if (head.sequence > 0) { + const terminal = head.operationSha256 === null ? undefined : provenanceBySha256.get(head.operationSha256); + if (terminal === undefined || terminal.sequence !== head.sequence || terminal.graphRevisionSha256 !== head.graphRevisionSha256 || terminal.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The remote space head differs from its terminal canonical operation."); + } + } + const materialized = recordResult.rows.map((row) => { + const json = rowValue(row, "record_json", 3); + if (typeof json !== "string") + throw new OhIntegrityError("A materialized remote record is not JSON text."); + let value; + try { + value = JSON.parse(json); + } catch { + throw new OhIntegrityError("A materialized remote record is invalid."); + } + const record = parseKnowledgeGraphRecordV1(value); + const operationSha256 = parseSha256Hex(rowValue(row, "operation_sha256", 4)); + const sequence = integer(rowValue(row, "sequence", 5)); + if (record === null || canonicalJson(record) !== json || operationSha256 === null || sequence === null || sequence < 1 || sequence > head.sequence || rowValue(row, "record_key", 0) !== record.key || rowValue(row, "kind", 1) !== record.kind || rowValue(row, "record_sha256", 2) !== record.recordSha256) { + throw new OhIntegrityError("A materialized remote record is invalid."); + } + const provenance = provenanceBySha256.get(operationSha256); + if (provenance === undefined || provenance.sequence !== sequence || !provenance.changes.some((change) => change.kind === "put" && canonicalJson(change.record) === json)) { + throw new OhIntegrityError("A materialized remote record has no exact canonical provenance put."); + } + return { record, sequence }; + }); + const records = materialized.map(({ record }) => record); + if (canonicalSha256(records.map(knowledgeGraphRecordRefV1)) !== head.recordsSha256) { + throw new OhIntegrityError("Materialized remote records do not reproduce the current head."); + } + const dependencyRows = dependencyResult.rows.map((row) => ({ + dependency_key: rowValue(row, "dependency_key", 1), + record_key: rowValue(row, "record_key", 0) + })); + const expectedDependencies = records.flatMap((record) => record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key }))); + if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { + throw new OhIntegrityError("Materialized remote dependencies do not match their record envelopes."); + } + return { head, records, v: 1 }; + } async snapshot(options = {}) { this.#assertOpen(); const current = await this.head(); const target = options.head === undefined ? current : await this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); - const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations - WHERE space_id = ? AND sequence <= ? ORDER BY sequence`, args: [this.binding.spaceId, target.sequence] })).rows; - const operations = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); - const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, options.maximumRecords); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + if (target.operationSha256 === current.operationSha256) { + return await this.#currentMaterializedSnapshot(current, maximumRecords); + } + if (target.sequence > OH_LIBSQL_STORE_LIMITS_V1.historyOperations) { + throw new RangeError("The requested libSQL history exceeds its operation replay bound."); + } + const historyResults = await this.#client.batch([ + { + sql: `SELECT count(*) AS count, min(operation.sequence) AS minimum, + max(operation.sequence) AS maximum, + coalesce(sum(length(CAST(operation.operation_json AS BLOB))), 0) AS canonical_bytes, + coalesce(sum(${OPERATION_RESPONSE_BYTES}), 0) AS response_bytes + FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ?`, + args: [this.binding.spaceId, target.sequence] + }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(length(CAST(candidate.operation_json AS BLOB))), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + AND (SELECT coalesce(sum(${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")}), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + ORDER BY operation.sequence`, args: [ + this.binding.spaceId, + target.sequence, + this.binding.spaceId, + target.sequence, + OH_LIBSQL_STORE_LIMITS_V1.historyBytes, + this.binding.spaceId, + target.sequence, + OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes + ] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] } + ], "read"); + if (historyResults.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete history batch."); + } + const [historySizeResult, historyRowResult, purgeResult] = historyResults; + const historySizeRow = historySizeResult.rows[0]; + const historyBytes = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "canonical_bytes", 3)); + const responseBytes = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "response_bytes", 4)); + if (historyBytes === null || historyBytes > OH_LIBSQL_STORE_LIMITS_V1.historyBytes || responseBytes === null || responseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes) { + throw new RangeError("The requested libSQL history exceeds its provider-safe replay bounds."); + } + const historyCount = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "count", 0)); + const minimumSequence = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "minimum", 1)); + const maximumSequence = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "maximum", 2)); + if (historyCount !== target.sequence || target.sequence === 0 && (rowValue(historySizeRow, "minimum", 1) !== null || rowValue(historySizeRow, "maximum", 2) !== null) || target.sequence > 0 && (minimumSequence !== 1 || maximumSequence !== target.sequence) || historyRowResult.rows.length !== historyCount) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote operation history does not exactly cover the requested head."); + } + const operations = historyRowResult.rows.map((row) => parseOperationRow(row, { spaceId: this.binding.spaceId })); + const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { throw new OhIntegrityError("Remote operation replay does not reproduce the requested head."); } @@ -1242,57 +1679,286 @@ class OhLibSqlStoreV1 { const from = parseOhHeadRefV1(fromValue); if (from === null) throw new TypeError("Invalid change-feed cursor."); - const limit = normalizeLimit(options.limit); - const current = await this.head(); - const fromHead = await this.#headAt(from); - const through = options.through === undefined ? current : await this.#headAt(options.through); + const requestedThrough = options.through === undefined ? undefined : parseOhHeadRefV1(options.through); + if (requestedThrough === null) + throw new TypeError("Invalid change-feed through head."); + const limit = normalizeLimit(options.limit, OH_LIBSQL_STORE_LIMITS_V1.changeFeedLimit, OH_LIBSQL_STORE_LIMITS_V1.changeFeedLimit); + const throughSequence = requestedThrough?.sequence ?? null; + const results = await this.#client.batch([ + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, from.sequence] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, throughSequence] }, + { sql: `SELECT count(*) AS count, coalesce(sum(response_bytes), 0) AS response_bytes FROM ( + SELECT ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS response_bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence > ? + AND candidate.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + ORDER BY candidate.sequence LIMIT ? + )`, args: [ + this.binding.spaceId, + from.sequence, + throughSequence, + this.binding.spaceId, + limit + 1 + ] }, + { + sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + AS operation WHERE operation.space_id = ? AND operation.sequence > ? + AND operation.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + AND (SELECT coalesce(sum(response_bytes), 0) FROM ( + SELECT ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS response_bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence > ? + AND candidate.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + ORDER BY candidate.sequence LIMIT ? + )) <= ? + ORDER BY operation.sequence LIMIT ?`, + args: [ + this.binding.spaceId, + from.sequence, + throughSequence, + this.binding.spaceId, + this.binding.spaceId, + from.sequence, + throughSequence, + this.binding.spaceId, + limit + 1, + OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes, + limit + 1 + ] + }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] } + ], "read"); + if (results.length !== 6) + throw new OhIntegrityError("The remote authority returned an incomplete change-feed batch."); + const [currentResult, fromResult, throughResult, pageSizeResult, pageResult, purgeResult] = results; + const currentRow = currentResult.rows[0]; + if (currentRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote Oh space disappeared while reading its change feed."); + } + const current = parseHeadRow(currentRow); + const resolveHead = (reference, result) => { + if (reference.sequence === 0) + return emptyOhHeadV1(); + const row = result.rows[0]; + if (row === undefined) + throw new OhConflictError("A requested change-feed head is not present in this space."); + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (operation.spaceId !== this.binding.spaceId || operation.sequence !== reference.sequence || operation.operationSha256 !== reference.operationSha256) { + throw new OhConflictError("A requested change-feed sequence identifies a different operation head."); + } + return { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + }; + const fromHead = resolveHead(from, fromResult); + const through = requestedThrough === undefined ? current : resolveHead(requestedThrough, throughResult); if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { throw new OhConflictError("The change-feed bounds do not identify one remote history prefix."); } - const rows = (await this.#client.execute({ - sql: `SELECT operation_json FROM oh_authority_operations - WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, - args: [this.binding.spaceId, fromHead.sequence, through.sequence, limit + 1] - })).rows; - const parsed = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const pageSizeRow = pageSizeResult.rows[0]; + const pageCount = pageSizeRow === undefined ? null : integer(rowValue(pageSizeRow, "count", 0)); + const pageResponseBytes = pageSizeRow === undefined ? null : integer(rowValue(pageSizeRow, "response_bytes", 1)); + if (pageCount === null || pageCount > limit + 1 || pageResponseBytes === null) { + throw new OhIntegrityError("The remote change feed returned invalid response bounds."); + } + if (pageResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes) { + throw new RangeError("The requested change-feed page exceeds its provider response bound."); + } + const parsed = pageResult.rows.map((row) => parseOperationRow(row, { spaceId: this.binding.spaceId })); + if (parsed.length !== pageCount) { + throw new OhIntegrityError("The remote change feed omitted provider-bounded rows."); + } const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); let prior = fromHead; - for (const operation of operations) { - if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { + for (const operation of parsed) { + if (operation.spaceId !== this.binding.spaceId || operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The remote change feed contains a gap or fork."); } prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; } + if (!hasMore && (prior.sequence !== through.sequence || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The remote change feed does not reach its pinned through head."); + } + const last = operations.at(-1); + const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; return { from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, hasMore, operations, through, - to: prior, + to, v: 1 }; } async#assertMaterializedSnapshot(snapshot) { - const rows = (await this.#client.execute({ sql: `SELECT record_json FROM oh_authority_records - WHERE space_id = ? ORDER BY record_key`, args: [this.binding.spaceId] })).rows; - const records = rows.map((row) => { - const json = rowValue(row, "record_json", 0); + if (snapshot.head.sequence > OH_LIBSQL_STORE_LIMITS_V1.historyOperations) { + throw new RangeError("The libSQL authority exceeds its explicit verification operation bound."); + } + const verificationResults = await this.#client.batch([ + { sql: `SELECT * FROM (WITH bounded_operation AS ( + SELECT * FROM oh_authority_operations + WHERE space_id = ? AND sequence <= ? + ) SELECT count(*) AS operation_count, min(operation.sequence) AS minimum, + max(operation.sequence) AS maximum, + coalesce(sum(length(CAST(operation.operation_json AS BLOB))), 0) AS canonical_bytes, + coalesce(sum(${OPERATION_RESPONSE_BYTES}), 0) AS operation_response_bytes, + (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) AS record_response_bytes, + (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ?) AS dependency_response_bytes, + (SELECT coalesce(sum(${OPERATION_RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_operation_records AS materialized + JOIN bounded_operation AS owner + ON owner.operation_sha256 = materialized.operation_sha256) AS operation_record_response_bytes + FROM bounded_operation AS operation)`, args: [ + this.binding.spaceId, + snapshot.head.sequence, + this.binding.spaceId, + this.binding.spaceId + ] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} + FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(length(CAST(candidate.operation_json AS BLOB))), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + AND (SELECT coalesce(sum(${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")}), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + ORDER BY operation.sequence`, args: [ + this.binding.spaceId, + snapshot.head.sequence, + this.binding.spaceId, + snapshot.head.sequence, + OH_LIBSQL_STORE_LIMITS_V1.historyBytes, + this.binding.spaceId, + snapshot.head.sequence, + OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes + ] }, + { sql: `SELECT record_key, kind, record_sha256, record_json, operation_sha256, sequence + FROM oh_authority_records AS record WHERE record.space_id = ? + AND (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) <= ? + ORDER BY record.record_key`, args: [ + this.binding.spaceId, + this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + ] }, + { sql: `SELECT record_key, dependency_key FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ? + AND (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency WHERE dependency.space_id = ?) <= ? + ORDER BY dependency.record_key, dependency.dependency_key`, args: [ + this.binding.spaceId, + this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + ] }, + { sql: `SELECT materialized.space_id, materialized.operation_sha256, materialized.ordinal, + materialized.record_key, materialized.change_kind, materialized.record_sha256 + FROM oh_authority_operation_records AS materialized + JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(${OPERATION_RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_operation_records AS materialized + JOIN oh_authority_operations AS owner + ON owner.operation_sha256 = materialized.operation_sha256 + WHERE owner.space_id = ? AND owner.sequence <= ?) <= ? + ORDER BY operation.sequence, materialized.ordinal`, args: [ + this.binding.spaceId, + snapshot.head.sequence, + this.binding.spaceId, + snapshot.head.sequence, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + ] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] } + ], "read"); + if (verificationResults.length !== 6) { + throw new OhIntegrityError("The remote authority returned an incomplete verification batch."); + } + const [sizeResult, operationResult, recordResult, dependencyResult, operationRecordResult, headResult] = verificationResults; + const sizeRow = sizeResult.rows[0]; + const operationCount = sizeRow === undefined ? null : integer(rowValue(sizeRow, "operation_count", 0)); + const minimumValue = sizeRow === undefined ? undefined : rowValue(sizeRow, "minimum", 1); + const maximumValue = sizeRow === undefined ? undefined : rowValue(sizeRow, "maximum", 2); + const minimumSequence = sizeRow === undefined ? null : integer(rowValue(sizeRow, "minimum", 1)); + const maximumSequence = sizeRow === undefined ? null : integer(rowValue(sizeRow, "maximum", 2)); + const historyBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "canonical_bytes", 3)); + const operationResponseBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "operation_response_bytes", 4)); + const recordResponseBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "record_response_bytes", 5)); + const dependencyResponseBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "dependency_response_bytes", 6)); + const operationRecordResponseBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "operation_record_response_bytes", 7)); + if (historyBytes === null || historyBytes > OH_LIBSQL_STORE_LIMITS_V1.historyBytes || operationResponseBytes === null || operationResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes || recordResponseBytes === null || recordResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes || dependencyResponseBytes === null || dependencyResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes || operationRecordResponseBytes === null || operationRecordResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes) { + throw new RangeError("The libSQL authority exceeds its provider-safe verification bounds."); + } + if (operationCount !== snapshot.head.sequence || snapshot.head.sequence === 0 && (minimumValue !== null || maximumValue !== null) || snapshot.head.sequence > 0 && (minimumSequence !== 1 || maximumSequence !== snapshot.head.sequence) || operationResult.rows.length !== operationCount) { + throw new OhIntegrityError("The remote operation history does not exactly cover its verified head."); + } + const headRow = headResult.rows[0]; + if (headRow === undefined) + throw new OhIntegrityError("The remote authority lost its head during verification."); + if (canonicalJson(parseHeadRow(headRow)) !== canonicalJson(snapshot.head)) { + throw new OhConflictError("The remote authority head changed during verification."); + } + const operations = operationResult.rows.map((row) => { + return parseOperationRow(row, { spaceId: this.binding.spaceId }); + }); + const replayed = replayOhOperationsV1(this.binding.spaceId, operations); + if (canonicalJson(replayed) !== canonicalJson(snapshot)) { + throw new OhIntegrityError("Remote operation replay changed during materialization verification."); + } + const materializedBy = new Map; + for (const operation of operations) { + for (const change of operation.changes) { + const key = change.kind === "put" ? change.record.key : change.key; + if (change.kind === "put") + materializedBy.set(key, { operationSha256: operation.operationSha256, sequence: operation.sequence }); + else + materializedBy.delete(key); + } + } + const records = recordResult.rows.map((row) => { + const json = rowValue(row, "record_json", 3); if (typeof json !== "string") throw new OhIntegrityError("A materialized remote record is not JSON text."); - const parsed = parseKnowledgeGraphRecordV1(JSON.parse(json)); - if (parsed === null || canonicalJson(parsed) !== json) + let value; + try { + value = JSON.parse(json); + } catch { throw new OhIntegrityError("A materialized remote record is invalid."); - return parsed; + } + const record = parseKnowledgeGraphRecordV1(value); + const provenance = record === null ? undefined : materializedBy.get(record.key); + if (record === null || canonicalJson(record) !== json || rowValue(row, "record_key", 0) !== record.key || rowValue(row, "kind", 1) !== record.kind || rowValue(row, "record_sha256", 2) !== record.recordSha256 || provenance === undefined || rowValue(row, "operation_sha256", 4) !== provenance.operationSha256 || integer(rowValue(row, "sequence", 5)) !== provenance.sequence) { + throw new OhIntegrityError("A materialized remote record differs from operation replay."); + } + return record; }); if (canonicalJson(records) !== canonicalJson(snapshot.records)) { throw new OhIntegrityError("Remote materialized records do not match operation replay."); } - const dependencyRows = (await this.#client.execute({ - sql: `SELECT record_key, dependency_key - FROM oh_authority_dependencies WHERE space_id = ? ORDER BY record_key, dependency_key`, - args: [this.binding.spaceId] - })).rows.map((row) => ({ + const dependencyRows = dependencyResult.rows.map((row) => ({ dependency_key: rowValue(row, "dependency_key", 1), record_key: rowValue(row, "record_key", 0) })); @@ -1300,11 +1966,123 @@ class OhLibSqlStoreV1 { if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { throw new OhIntegrityError("Remote materialized dependencies do not match operation replay."); } + const operationRecordRows = operationRecordResult.rows.map((row) => ({ + change_kind: rowValue(row, "change_kind", 4), + operation_sha256: rowValue(row, "operation_sha256", 1), + ordinal: integer(rowValue(row, "ordinal", 2)), + record_key: rowValue(row, "record_key", 3), + record_sha256: rowValue(row, "record_sha256", 5), + space_id: rowValue(row, "space_id", 0) + })); + const expectedOperationRecords = operations.flatMap((operation) => operation.changes.map((change, ordinal) => ({ + change_kind: change.kind, + operation_sha256: operation.operationSha256, + ordinal, + record_key: change.kind === "put" ? change.record.key : change.key, + record_sha256: change.kind === "put" ? change.record.recordSha256 : change.priorSha256, + space_id: this.binding.spaceId + }))); + if (canonicalJson(operationRecordRows) !== canonicalJson(expectedOperationRecords)) { + throw new OhIntegrityError("Remote operation-record rows do not match operation replay."); + } } async#operationById(operationId) { - const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + const row = await queryOne(this.#client, { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); - return row === null ? null : parseOperationJson(rowValue(row, "operation_json", 0)); + if (row === null) + return null; + return parseOperationRow(row, { operationId, spaceId: this.binding.spaceId }); + } + async#commitPreflight(operationId) { + const results = await this.#client.batch([ + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] } + ], "read"); + if (results.length !== 3) + throw new OhIntegrityError("The remote authority returned an incomplete commit preflight."); + const [duplicateResult, headResult, purgeResult] = results; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote space disappeared during commit preflight."); + } + const duplicateRow = duplicateResult.rows[0]; + return { current: parseHeadRow(headRow), duplicate: duplicateRow === undefined ? null : parseOperationRow(duplicateRow, { operationId, spaceId: this.binding.spaceId }) }; + } + async#assertOperationReachable(operation, expectedHead) { + if (operation.sequence < 1 || operation.sequence > expectedHead.sequence) { + throw new OhIntegrityError("A remote idempotent operation is not reachable from the current head."); + } + const results = await this.#client.batch([ + { sql: `SELECT * FROM (WITH RECURSIVE authority_chain(sequence, operation_sha256) AS ( + SELECT sequence, operation_sha256 FROM oh_authority_operations + WHERE space_id = ? AND sequence = ? AND operation_sha256 = ? + UNION ALL + SELECT candidate.sequence, candidate.operation_sha256 + FROM oh_authority_operations AS candidate + JOIN authority_chain AS prior + ON candidate.space_id = ? AND candidate.sequence = prior.sequence + 1 + AND candidate.parent_operation_sha256 = prior.operation_sha256 + WHERE candidate.sequence <= ? + ) SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum, + (SELECT operation_sha256 FROM authority_chain ORDER BY sequence DESC LIMIT 1) AS terminal_sha256 + FROM authority_chain)`, args: [ + this.binding.spaceId, + operation.sequence, + operation.operationSha256, + this.binding.spaceId, + expectedHead.sequence + ] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, expectedHead.sequence] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] } + ], "read"); + if (results.length !== 4) + throw new OhIntegrityError("The remote authority returned an incomplete reachability proof."); + const [chainResult, terminalResult, headResult, purgeResult] = results; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote space disappeared during an idempotency proof."); + } + const current = parseHeadRow(headRow); + if (canonicalJson(current) !== canonicalJson(expectedHead)) { + throw new OhConflictError("The remote space head changed during an idempotency proof."); + } + const chain = chainResult.rows[0]; + const count = chain === undefined ? null : integer(rowValue(chain, "count", 0)); + const minimum = chain === undefined ? null : integer(rowValue(chain, "minimum", 1)); + const maximum = chain === undefined ? null : integer(rowValue(chain, "maximum", 2)); + const terminalSha256 = chain === undefined ? null : rowValue(chain, "terminal_sha256", 3); + if (count !== expectedHead.sequence - operation.sequence + 1 || minimum !== operation.sequence || maximum !== expectedHead.sequence || terminalSha256 !== expectedHead.operationSha256) { + throw new OhIntegrityError("A remote idempotent operation has no exact path to the current head."); + } + const terminalRow = terminalResult.rows[0]; + if (terminalRow === undefined || expectedHead.operationSha256 === null) { + throw new OhIntegrityError("The remote current head operation is missing."); + } + const terminal = parseOperationRow(terminalRow, { + operationSha256: expectedHead.operationSha256, + spaceId: this.binding.spaceId + }); + if (terminal.sequence !== expectedHead.sequence || terminal.graphRevisionSha256 !== expectedHead.graphRevisionSha256 || terminal.recordsSha256 !== expectedHead.recordsSha256) { + throw new OhIntegrityError("The remote space head differs from its terminal canonical operation."); + } } async commit(input) { this.#assertOpen(); @@ -1313,19 +2091,25 @@ class OhLibSqlStoreV1 { const changes = canonicalKnowledgeGraphChangesV1(input.changes); if (actorId === null || operationId === null || changes.length === 0) throw new TypeError("Invalid Oh commit input."); - const duplicate = await this.#operationById(operationId); + if (changes.length > OH_LIBSQL_STORE_LIMITS_V1.changesPerCommit) { + throw new RangeError("A direct libSQL commit exceeds its change-count bound."); + } + const dependencies = changes.reduce((count, change) => count + (change.kind === "put" ? change.record.dependencies.length : 0), 0); + if (dependencies > OH_LIBSQL_STORE_LIMITS_V1.dependenciesPerCommit) { + throw new RangeError("A direct libSQL commit exceeds its dependency-count bound."); + } + const { current, duplicate } = await this.#commitPreflight(operationId); if (duplicate !== null) { + await this.#assertOperationReachable(duplicate, current); if (duplicate.actorId !== actorId || canonicalJson(duplicate.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return duplicate; } - const current = await this.head(); if (!Number.isSafeInteger(input.expectedHead.generation) || input.expectedHead.generation < 0 || current.generation !== input.expectedHead.generation || current.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current remote space head."); } - const snapshot = await this.snapshot({ head: current }); - await this.#assertMaterializedSnapshot(snapshot); + const snapshot = await this.#currentMaterializedSnapshot(current, OH_GRAPH_LIMITS_V1.recordsPerSnapshot); const transition = transitionOhSnapshotV1({ actorId, changes, @@ -1335,6 +2119,9 @@ class OhLibSqlStoreV1 { spaceId: this.binding.spaceId }); const operation = transition.operation; + if (utf8ByteLength(canonicalJson(operation)) > OH_LIBSQL_STORE_LIMITS_V1.operationBytes) { + throw new RangeError("A direct libSQL operation exceeds its canonical byte bound."); + } const existsOperation = "EXISTS (SELECT 1 FROM oh_authority_operations WHERE operation_sha256 = ?)"; const statements = [{ sql: `INSERT INTO oh_authority_operations(operation_sha256, space_id, sequence, @@ -1362,10 +2149,18 @@ class OhLibSqlStoreV1 { const key = change.kind === "put" ? change.record.key : change.key; const digest = change.kind === "put" ? change.record.recordSha256 : change.priorSha256; statements.push({ - sql: `INSERT INTO oh_authority_operation_records(operation_sha256, + sql: `INSERT INTO oh_authority_operation_records(space_id, operation_sha256, ordinal, record_key, change_kind, record_sha256) - SELECT ?, ?, ?, ?, ? WHERE ${existsOperation}`, - args: [operation.operationSha256, ordinal, key, change.kind, digest, operation.operationSha256] + SELECT ?, ?, ?, ?, ?, ? WHERE ${existsOperation}`, + args: [ + this.binding.spaceId, + operation.operationSha256, + ordinal, + key, + change.kind, + digest, + operation.operationSha256 + ] }); statements.push({ sql: `DELETE FROM oh_authority_dependencies WHERE space_id = ? AND record_key = ? AND ${existsOperation}`, args: [this.binding.spaceId, key, operation.operationSha256] }); @@ -1430,20 +2225,36 @@ class OhLibSqlStoreV1 { WHERE space_id = ? AND generation = ? AND head_operation_sha256 = ?)`, args: [this.binding.spaceId, operation.sequence, operation.operationSha256] }); + statements.push({ sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); + statements.push({ sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }); + let writeResults; try { - await this.#client.batch(statements, "write"); + writeResults = await this.#client.batch(statements, "write"); } catch (error) { const raced = await this.#operationById(operationId); - if (raced !== null && raced.actorId === actorId && canonicalJson(raced.changes) === canonicalJson(changes)) - return raced; const head = await this.head(); + if (raced !== null && raced.actorId === actorId && canonicalJson(raced.changes) === canonicalJson(changes)) { + await this.#assertOperationReachable(raced, head); + return raced; + } if (head.operationSha256 !== current.operationSha256) { throw new OhConflictError("The remote space head changed while committing."); } throw error; } - const persisted = await this.#operationById(operationId); - if (persisted === null || canonicalJson(persisted) !== canonicalJson(operation)) { + if (writeResults.length !== statements.length) { + throw new OhIntegrityError("The remote authority returned an incomplete commit result batch."); + } + const persistedRow = writeResults.at(-2)?.rows[0]; + const persistedHeadRow = writeResults.at(-1)?.rows[0]; + if (persistedRow === undefined || persistedHeadRow === undefined) { + throw new OhIntegrityError("The remote authority omitted its persisted commit result."); + } + const persisted = parseOperationRow(persistedRow, { operationId, spaceId: this.binding.spaceId }); + const persistedHead = parseHeadRow(persistedHeadRow); + if (canonicalJson(persisted) !== canonicalJson(operation) || persistedHead.operationSha256 !== operation.operationSha256 || persistedHead.sequence !== operation.sequence || persistedHead.graphRevisionSha256 !== operation.graphRevisionSha256 || persistedHead.recordsSha256 !== operation.recordsSha256) { throw new OhIntegrityError("The remote authority did not persist the committed operation exactly."); } return persisted; @@ -1467,20 +2278,52 @@ class OhLibSqlStoreV1 { this.#assertOpen(); const snapshot = await this.snapshot(); await this.#assertMaterializedSnapshot(snapshot); - const countRow = await queryOne(this.#client, { sql: `SELECT count(*) AS count - FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] }); - const operations = countRow === null ? null : integer(rowValue(countRow, "count", 0)); - if (operations === null || operations !== snapshot.head.sequence) { - throw new OhIntegrityError("Remote operation count does not match its head."); - } return { head: snapshot.head, integrity: "verified", - operations, + operations: snapshot.head.sequence, records: snapshot.records.length, v: 1 }; } + async#assertPurgeComplete(expected) { + const tables = [ + "oh_authority_spaces", + "oh_authority_bindings", + "oh_authority_operations", + "oh_authority_operation_records", + "oh_authority_records", + "oh_authority_dependencies" + ]; + const results = await this.#client.batch([ + { + sql: PURGE_ROW_SELECT, + args: [this.binding.spaceId] + }, + ...tables.map((table) => ({ + sql: `SELECT count(*) AS count FROM ${table} WHERE space_id = ?`, + args: [this.binding.spaceId] + })), + { sql: `SELECT count(*) AS count FROM oh_authority_operation_records AS materialized + LEFT JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.operation_sha256 IS NULL OR operation.space_id <> materialized.space_id` } + ], "read"); + const receiptRow = results[0]?.rows[0]; + if (receiptRow === undefined || canonicalJson(parsePurgeReceiptRow(receiptRow, this.binding.spaceId, this.binding.bindingSha256)) !== canonicalJson(expected)) { + throw new OhIntegrityError("The remote purge receipt differs from the requested purge."); + } + for (let index = 0;index < tables.length; index += 1) { + const countRow = results[index + 1]?.rows[0]; + if (countRow === undefined || integer(rowValue(countRow, "count", 0)) !== 0) { + throw new OhIntegrityError(`Remote purge left rows in ${tables[index]}.`); + } + } + const orphanRow = results[tables.length + 1]?.rows[0]; + if (orphanRow === undefined || integer(rowValue(orphanRow, "count", 0)) !== 0) { + throw new OhIntegrityError("Remote purge left an orphaned or cross-space operation record."); + } + } async purgeWorkingSpace(purgedAt) { this.#assertOpen(); if (this.binding.profile.profileKind !== "working" || !this.binding.profile.capabilities.wholeSpacePurge) { @@ -1489,6 +2332,7 @@ class OhLibSqlStoreV1 { for (let attempt = 0;attempt < 3; attempt += 1) { const existing = await this.#readPurge(); if (existing !== null) { + await this.#assertPurgeComplete(existing); this.#purged = existing; return existing; } @@ -1521,9 +2365,9 @@ class OhLibSqlStoreV1 { args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); statements.push({ - sql: `DELETE FROM oh_authority_operation_records WHERE operation_sha256 IN - (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) - AND ${receiptExists}`, + sql: `DELETE FROM oh_authority_operation_records + WHERE operation_sha256 IN (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) + AND ${receiptExists}`, args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); statements.push(guardedDelete("oh_authority_dependencies")); @@ -1534,14 +2378,33 @@ class OhLibSqlStoreV1 { statements.push({ sql: `INSERT INTO oh_authority_commit_guards(value) SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operations WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operation_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operation_records AS materialized + LEFT JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.operation_sha256 IS NULL OR operation.space_id <> materialized.space_id) + OR EXISTS (SELECT 1 FROM oh_authority_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_dependencies WHERE space_id = ?) OR NOT ${receiptExists}`, - args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] + args: [ + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + this.binding.spaceId, + receipt.receiptSha256 + ] }); try { await this.#client.batch(statements, "write"); } catch { const raced = await this.#readPurge(); if (raced !== null) { + await this.#assertPurgeComplete(raced); this.#purged = raced; return raced; } @@ -1549,6 +2412,7 @@ class OhLibSqlStoreV1 { } const persisted = await this.#readPurge(); if (persisted !== null) { + await this.#assertPurgeComplete(persisted); this.#purged = persisted; return persisted; } @@ -1604,5 +2468,6 @@ async function createOhLibSqlStoreAuthorityV1(client, options = {}) { } export { createOhLibSqlStoreAuthorityV1, - bootstrapOhLibSqlAuthorityV1 + bootstrapOhLibSqlAuthorityV1, + OH_LIBSQL_STORE_LIMITS_V1 }; diff --git a/dist/memory.d.ts b/dist/memory.d.ts new file mode 100644 index 0000000..3c2e0b8 --- /dev/null +++ b/dist/memory.d.ts @@ -0,0 +1,231 @@ +import { type JsonPrimitive, type Sha256Hex } from "./canonical"; +import { type OhRecordCodecRegistry } from "./contract"; +import { type KnowledgeGraphRecordV1 } from "./graph"; +import { type OhProjectionAtomV1, type OhProjectionEvaluationOptionsV1, type OhProjectionQueryV1, type OhProjectionRulePackV1 } from "./projection"; +import { type OhDependencyClosureV1, type OhHeadV1, type OhStoreV1 } from "./store"; +export declare const OH_MEMORY_FORMAT_VERSION_V1: 1; +export declare const OH_MEMORY_CONFLICT_POLICY_V1: "visible-conflicts.v1"; +export declare const OH_MEMORY_LIMITS_V1: Readonly<{ + explainCapabilityEntryBytes: number; + explainCapabilities: 256; + explainCapabilityLifetimeMs: number; + explainCapabilityTotalBytes: number; + factsPerRecordPerExtractor: 512; + maximumExtractorInvocations: 262144; + maximumExtractors: 32; + maximumNominationRoutes: 64; + maximumPrograms: 128; + maximumRecordsPerLane: 8192; + maximumSyntheticRecords: 16384; + rememberBytes: number; + resultBytes: number; + snapshotBytesPerLane: number; + relationsPerExtractor: 64; +}>; +export declare const OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1: Readonly<{ + extractorSha256: Sha256Hex; + factPackId: "oh.memory.composite-facts"; + factPackRevision: 1; + relations: readonly string[]; + semantics: "oh.projection.positive-datalog.v1"; + v: 1; +}>; +export type OhMemoryLaneV1 = "canonical" | "working"; +export type OhMemoryAuthoritySourceV1 = Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + key: string; + lane: OhMemoryLaneV1; + recordSha256: Sha256Hex; + snapshotSha256: Sha256Hex; + v: 1; +}>; +export type OhMemoryProofV1 = Readonly<{ + factPolicy: OhMemoryFactPolicyV1; + kind: "fact"; + relation: string; + sources: readonly OhMemoryAuthoritySourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}> | Readonly<{ + kind: "derived"; + premises: readonly OhMemoryProofV1[]; + premisesTruncated: boolean; + relation: string; + ruleId: string; + ruleSha256: Sha256Hex; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}> | Readonly<{ + kind: "truncated"; + reason: "cycle" | "depth" | "nodes"; + relation: string; + tuple: readonly OhProjectionAtomV1[]; + v: 1; +}>; +export type OhMemoryLaneIdentityV1 = Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + datasetSha256: Sha256Hex; + head: OhHeadV1; + lane: OhMemoryLaneV1; + snapshotSha256: Sha256Hex; + v: 1; +}>; +export type OhMemoryIdentityV1 = Readonly<{ + canonical: OhMemoryLaneIdentityV1; + compositeDatasetSha256: Sha256Hex; + conflictPolicy: typeof OH_MEMORY_CONFLICT_POLICY_V1; + evaluationSha256: Sha256Hex; + memorySha256: Sha256Hex; + programId: string; + projectionSha256: Sha256Hex; + purpose: string; + querySha256: Sha256Hex; + rulePackSha256: Sha256Hex; + v: 1; + working: OhMemoryLaneIdentityV1; +}>; +export type OhMemoryConflictV1 = Readonly<{ + canonicalRecordSha256: Sha256Hex; + key: string; + v: 1; + workingRecordSha256: Sha256Hex; +}>; +export type OhMemoryResultRowV1 = Readonly<{ + premiseAuthority: "canonical" | "unknown" | "working"; + premiseLanes: readonly OhMemoryLaneV1[]; + proofsTruncated: boolean; + resultRowSha256: Sha256Hex; + supportCount: number; + v: 1; + values: readonly OhProjectionAtomV1[]; +}>; +export type OhMemoryQueryResultV1 = Readonly<{ + authority: "derived"; + conflicts: readonly OhMemoryConflictV1[]; + explainCapability: Readonly<{ + expiresAt: string; + token: string; + v: 1; + }>; + identity: OhMemoryIdentityV1; + projectionResultSha256: Sha256Hex; + resultSha256: Sha256Hex; + rows: readonly OhMemoryResultRowV1[]; + v: 1; +}>; +export type OhMemoryRememberReceiptV1 = Readonly<{ + actorId: string; + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + instant: string; + lane: "working"; + operationSha256: Sha256Hex; + receiptSha256: Sha256Hex; + requestId: string; + status: "committed"; + v: 1; +}>; +export type OhMemoryExplanationV1 = Readonly<{ + authority: "derived"; + explanationSha256: Sha256Hex; + identity: OhMemoryIdentityV1; + premiseAuthority: OhMemoryResultRowV1["premiseAuthority"]; + premiseLanes: readonly OhMemoryLaneV1[]; + proofs: readonly OhMemoryProofV1[]; + proofsTruncated: boolean; + resultRowSha256: Sha256Hex; + resultSha256: Sha256Hex; + supportCount: number; + v: 1; + values: readonly OhProjectionAtomV1[]; +}>; +export type OhMemoryNominationV1 = Readonly<{ + closure: OhDependencyClosureV1; + destinationPurpose: string; + nominationId: string; + nominationSha256: Sha256Hex; + source: Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + lane: "working"; + v: 1; + }>; + status: "prepared"; + v: 1; +}>; +export type OhMemoryNamedProgramV1 = Readonly<{ + evaluation?: OhProjectionEvaluationOptionsV1; + programId: string; + purpose: string; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; +}>; +export type OhMemoryNominationRouteV1 = Readonly<{ + destinationPurpose: string; + nominationId: string; +}>; +export type OhMemoryFactPolicyV1 = Readonly<{ + extractorSha256: Sha256Hex; + factPackId: string; + kind: "built-in"; + v: 1; +}> | Readonly<{ + extractorId: string; + extractorSha256: Sha256Hex; + kind: "domain"; + v: 1; +}>; +export type OhMemoryFactDeclarationV1 = Readonly<{ + relation: string; + tuple: readonly JsonPrimitive[]; + v: 1; +}>; +/** Host-owned, digest-identified domain projection; it cannot choose sources. */ +export type OhMemoryFactExtractorV1 = Readonly<{ + extract(input: Readonly<{ + lane: OhMemoryLaneV1; + record: KnowledgeGraphRecordV1; + }>): readonly OhMemoryFactDeclarationV1[]; + extractorId: string; + extractorSha256: Sha256Hex; + relations: readonly string[]; +}>; +export type OhMemoryFacadeOptionsV1 = Readonly<{ + actorId: string; + canonical: Readonly<{ + authorityId: string; + expectedBindingSha256: Sha256Hex; + expectedHead: OhHeadV1; + store: OhStoreV1; + }>; + explainCapabilityLifetimeMs?: number; + extractors?: readonly OhMemoryFactExtractorV1[]; + monotonicNow?: () => number; + nominationRoutes?: readonly OhMemoryNominationRouteV1[]; + now?: () => Date; + programs: readonly OhMemoryNamedProgramV1[]; + working: Readonly<{ + authorityId: string; + codecs: OhRecordCodecRegistry; + expectedBindingSha256: Sha256Hex; + store: OhStoreV1; + }>; +}>; +export interface OhMemoryAgentV1 { + explain(value: unknown): Promise; + nominate(value: unknown): Promise; + query(value: unknown): Promise; + remember(value: unknown): Promise; +} +/** + * Creates a model-facing memory surface over two host-bound physical Oh + * authorities. The returned object has no store, locator, rule, sync, canonical + * write, or purge handle. + */ +export declare function createOhMemoryAgentV1(options: OhMemoryFacadeOptionsV1): Promise; +//# sourceMappingURL=memory.d.ts.map \ No newline at end of file diff --git a/dist/memory.d.ts.map b/dist/memory.d.ts.map new file mode 100644 index 0000000..d65e9ce --- /dev/null +++ b/dist/memory.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/memory.js b/dist/memory.js new file mode 100644 index 0000000..25d8bee --- /dev/null +++ b/dist/memory.js @@ -0,0 +1,3007 @@ +// src/canonical.ts +import { createHash, randomBytes } from "node:crypto"; + +class OhValidationError extends Error { + code; + path; + constructor(code, path, message) { + super(`${path}: ${message}`); + this.name = "OhValidationError"; + this.code = code; + this.path = path; + } +} +function isPlainRecord(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasExactKeys(value, keys) { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function assertUnicodeScalarString(value, path) { + 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)) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + index += 1; + } else if (code >= 56320 && code <= 57343) { + throw new OhValidationError("invalid-unicode", path, "contains an unpaired surrogate"); + } + } +} +function encodeCanonical(value, path, ancestors) { + if (value === null || typeof value === "boolean") + return JSON.stringify(value); + if (typeof value === "string") { + assertUnicodeScalarString(value, path); + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new OhValidationError("non-json-number", path, "must be finite"); + } + if (Object.is(value, -0)) { + throw new OhValidationError("noncanonical-number", path, "negative zero is not canonical"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || value === null) { + throw new OhValidationError("non-json-value", path, `cannot encode ${typeof value}`); + } + if (ancestors.has(value)) { + throw new OhValidationError("cycle", path, "contains a cycle"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded = []; + for (let index = 0;index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes"); + } + encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors)); + } + const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)); + if (extraKeys.length > 0) { + throw new OhValidationError("non-json-property", path, "array has non-index properties"); + } + return `[${encoded.join(",")}]`; + } + if (!isPlainRecord(value)) { + throw new OhValidationError("non-plain-object", path, "must be a plain object"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new OhValidationError("non-json-property", path, "object has a symbol property"); + } + const keys = ownKeys; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property"); + } + } + keys.sort(); + const entries = keys.map((key) => { + assertUnicodeScalarString(key, `${path}.`); + return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`; + }); + return `{${entries.join(",")}}`; + } finally { + ancestors.delete(value); + } +} +function canonicalJson(value) { + return encodeCanonical(value, "$", new Set); +} +function parseCanonicalJson(text, maximumBytes = 16 * 1024 * 1024) { + if (utf8ByteLength(text) > maximumBytes) { + throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit"); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new OhValidationError("invalid-json", "$", "is not valid JSON"); + } + if (canonicalJson(value) !== text) { + throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical"); + } + return value; +} +function utf8ByteLength(value) { + return Buffer.byteLength(value, "utf8"); +} +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} +function canonicalSha256(value) { + return sha256Hex(canonicalJson(value)); +} +function parseSha256Hex(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value) ? value : null; +} +function parseCanonicalInstantV1(value) { + if (typeof value !== "string" || !/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u.test(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? value : null; +} +function canonicalNow() { + return new Date().toISOString(); +} +function safeCode(value, maximumLength = 128) { + return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function orderedUnique(values, key) { + return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); +} +function sortUnique(values, key) { + const sorted = [...values].sort((left, right) => { + const leftKey = key(left); + const rightKey = key(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + if (!orderedUnique(sorted, key)) { + throw new OhValidationError("duplicate", "$", "contains duplicate canonical values"); + } + return sorted; +} + +// src/graph.ts +var OH_GRAPH_FORMAT_VERSION_V1 = 1; +var OH_GRAPH_LIMITS_V1 = Object.freeze({ + changesPerOperation: 8192, + dependenciesPerRecord: 4096, + recordBytes: 1024 * 1024, + recordsPerSnapshot: 65536 +}); +var OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1 = [ + "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 recordKey(value) { + return typeof value === "string" && value.length <= 512 && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; +} +function createKnowledgeGraphRecordV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"]) || input.v !== 1 || !Array.isArray(input.dependencies)) + throw new TypeError("Invalid graph record input."); + const key = recordKey(input.key); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind); + if (key === null || kind === undefined || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) + throw new TypeError("Invalid graph record identity."); + const dependencies = input.dependencies.map(recordKey); + if (dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) { + throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive."); + } + const valueJson = canonicalJson(input.value); + if (Buffer.byteLength(valueJson, "utf8") > OH_GRAPH_LIMITS_V1.recordBytes) { + throw new RangeError("Graph record value exceeds its canonical byte limit."); + } + const payload = { dependencies, key, kind, v: 1, value: input.value }; + return { ...payload, recordSha256: canonicalSha256(payload) }; +} +function parseKnowledgeGraphRecordV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) + return null; + const recordSha256 = parseSha256Hex(value.recordSha256); + const { recordSha256: _digest, ...input } = value; + try { + const created = createKnowledgeGraphRecordV1(input); + return recordSha256 !== null && created.recordSha256 === recordSha256 ? { ...created, recordSha256 } : null; + } catch { + return null; + } +} +function knowledgeGraphRecordRefV1(record) { + return { + dependencies: record.dependencies, + key: record.key, + kind: record.kind, + sha256: record.recordSha256, + v: 1 + }; +} +function changeKey(change) { + return change.kind === "put" ? change.record.key : change.key; +} +function canonicalKnowledgeGraphChangesV1(changes) { + const normalized = []; + for (const change of changes) { + if (!isPlainRecord(change) || change.v !== 1) + throw new TypeError("Invalid graph change."); + if (change.kind === "put") { + const record = parseKnowledgeGraphRecordV1(change.record); + if (record === null) + throw new TypeError("Invalid graph record in change."); + normalized.push({ kind: "put", record, v: 1 }); + } else if (change.kind === "tombstone") { + const key = recordKey(change.key); + const priorSha256 = parseSha256Hex(change.priorSha256); + if (key === null || priorSha256 === null) + throw new TypeError("Invalid graph tombstone."); + normalized.push({ key, kind: "tombstone", priorSha256, v: 1 }); + } else + throw new TypeError("Unknown graph change kind."); + } + return sortUnique(normalized, changeKey); +} +function graphRevisionSha256V1(input) { + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + const operationId = safeCode(input.operationId); + const parentGraphRevisionSha256 = input.parentGraphRevisionSha256 === null ? null : parseSha256Hex(input.parentGraphRevisionSha256); + const recordsSha256 = parseSha256Hex(input.recordsSha256); + const revision = Number.isSafeInteger(input.revision) && input.revision > 0 ? input.revision : null; + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation || operationId === null || recordsSha256 === null || revision === null || input.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || revision === 1 !== (parentGraphRevisionSha256 === null)) { + throw new TypeError("Invalid graph revision digest input."); + } + return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); +} + +// src/ontology.ts +var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; +var OH_CONTRACT_ID_V1 = "oh.ontology.v1"; +var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ + dimensions: 64, + listValues: 256, + qualifiers: 128, + statementBytes: 256 * 1024, + textBytes: 64 * 1024 +}); + +// src/schema.ts +var OH_SCHEMA_FORMAT_VERSION_V1 = 1; + +// src/contract.ts +var manifestPayload = Object.freeze({ + contractId: OH_CONTRACT_ID_V1, + graphFormatVersion: OH_GRAPH_FORMAT_VERSION_V1, + ontologyVersion: OH_ONTOLOGY_VERSION_V1, + recordKinds: OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1, + schemaFormatVersion: OH_SCHEMA_FORMAT_VERSION_V1, + v: 1 +}); +var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ + ...manifestPayload, + contractSha256: canonicalSha256(manifestPayload) +}); +class OhRecordCodecRegistry { + #codecs = new Map; + #sealed = false; + register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); + if (this.#codecs.has(codec.kind)) + throw new TypeError(`A codec is already registered for ${codec.kind}.`); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); + return this; + } + parse(kind, value) { + const codec = this.#codecs.get(kind); + if (codec !== undefined) + return codec.parse(value); + try { + canonicalJson(value); + return value; + } catch { + return null; + } + } + has(kind) { + return this.#codecs.has(kind); + } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } +} +// src/operation.ts +var OH_OPERATION_MAX_BYTES_V1 = 64 * 1024 * 1024; +function parsePayload(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "changes", + "contractId", + "graphRevisionSha256", + "instant", + "operationId", + "parentOperationSha256", + "recordsSha256", + "sequence", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractId !== OH_CONTRACT_ID_V1 || !Array.isArray(value.changes)) + return null; + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const spaceId = safeCode(value.spaceId); + const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); + const parentOperationSha256 = value.parentOperationSha256 === null ? null : parseSha256Hex(value.parentOperationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const instant = parseCanonicalInstantV1(value.instant); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence > 0 ? value.sequence : null; + let changes; + try { + changes = canonicalKnowledgeGraphChangesV1(value.changes); + } catch { + return null; + } + if (changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) + return null; + return actorId !== null && operationId !== null && spaceId !== null && graphRevisionSha256 !== null && recordsSha256 !== null && instant !== null && sequence !== null && (value.parentOperationSha256 === null || parentOperationSha256 !== null) && sequence === 1 === (parentOperationSha256 === null) ? { + actorId, + changes, + contractId: OH_CONTRACT_ID_V1, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256, + recordsSha256, + sequence, + spaceId, + v: 1 + } : null; +} +function createOhOperationV1(input) { + const payload = parsePayload(input); + if (payload === null) + throw new TypeError("Invalid Oh operation payload."); + const operation = { ...payload, operationSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(operation), "utf8") > OH_OPERATION_MAX_BYTES_V1) { + throw new RangeError("Oh operation exceeds its canonical byte limit."); + } + return operation; +} +function parseOhOperationV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "operationSha256")) + return null; + const operationSha256 = parseSha256Hex(value.operationSha256); + const { operationSha256: _digest, ...input } = value; + const payload = parsePayload(input); + return operationSha256 !== null && payload !== null && Buffer.byteLength(canonicalJson({ ...payload, operationSha256 }), "utf8") <= OH_OPERATION_MAX_BYTES_V1 && canonicalSha256(payload) === operationSha256 ? { ...payload, operationSha256 } : null; +} + +// src/store.ts +class OhConflictError extends Error { + constructor(message) { + super(message); + this.name = "OhConflictError"; + } +} + +class OhIntegrityError extends Error { + constructor(message) { + super(message); + this.name = "OhIntegrityError"; + } +} + +class OhDependencyError extends Error { + constructor(message) { + super(message); + this.name = "OhDependencyError"; + } +} + +class OhProfileError extends Error { + constructor(message) { + super(message); + this.name = "OhProfileError"; + } +} +var OH_CANONICAL_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: true, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: false + }, + profileId: "oh.store.canonical.v1", + profileKind: "canonical", + v: 1 +}); +var OH_WORKING_STORE_PROFILE_V1 = createOhStoreProfileV1({ + applicationProfileSha256: null, + capabilities: { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: false, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: true + }, + profileId: "oh.store.working.v1", + profileKind: "working", + v: 1 +}); +var OH_DEPENDENCY_CLOSURE_LIMITS_V1 = Object.freeze({ + bytes: 64 * 1024 * 1024, + records: 8192, + roots: 1024 +}); + +class OhPurgedSpaceError extends Error { + receipt; + constructor(receipt) { + super(`Oh space ${receipt.spaceId} was purged at ${receipt.purgedAt}.`); + this.name = "OhPurgedSpaceError"; + this.receipt = receipt; + } +} +var EMPTY_RECORDS_SHA256 = canonicalSha256([]); +function emptyOhHeadV1() { + return { + generation: 0, + graphRevisionSha256: null, + operationSha256: null, + recordsSha256: EMPTY_RECORDS_SHA256, + sequence: 0, + v: 1 + }; +} +function parseOhHeadV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "generation", + "graphRevisionSha256", + "operationSha256", + "recordsSha256", + "sequence", + "v" + ]) || value.v !== 1) + return null; + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const generation = Number.isSafeInteger(value.generation) && value.generation >= 0 ? value.generation : null; + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return generation !== null && sequence !== 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 parseOhHeadRefV1(value) { + const complete = parseOhHeadV1(value); + if (complete !== null) { + return { operationSha256: complete.operationSha256, sequence: complete.sequence }; + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["operationSha256", "sequence"])) + return null; + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const sequence = Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + return sequence !== null && (value.operationSha256 === null || operationSha256 !== null) && sequence === 0 === (operationSha256 === null) ? { operationSha256, sequence } : null; +} +function parseCapabilities(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "changesSince", + "dependencyClosureExport", + "exactSnapshots", + "operationReplication", + "semanticBundleCommit", + "v", + "wholeSpacePurge" + ]) || value.changesSince !== true || value.dependencyClosureExport !== true || value.exactSnapshots !== true || typeof value.operationReplication !== "boolean" || value.semanticBundleCommit !== true || value.v !== 1 || typeof value.wholeSpacePurge !== "boolean") + return null; + return { + changesSince: true, + dependencyClosureExport: true, + exactSnapshots: true, + operationReplication: value.operationReplication, + semanticBundleCommit: true, + v: 1, + wholeSpacePurge: value.wholeSpacePurge + }; +} +function createOhStoreProfileV1(input) { + if (!isPlainRecord(input) || !hasExactKeys(input, [ + "applicationProfileSha256", + "capabilities", + "profileId", + "profileKind", + "v" + ]) || input.v !== 1) + throw new TypeError("Invalid Oh store profile input."); + const profileId = safeCode(input.profileId); + const applicationProfileSha256 = input.applicationProfileSha256 === null ? null : parseSha256Hex(input.applicationProfileSha256); + const capabilities = parseCapabilities(input.capabilities); + if (profileId === null || capabilities === null || input.applicationProfileSha256 !== null && applicationProfileSha256 === null || input.profileKind !== "canonical" && input.profileKind !== "working") { + throw new TypeError("Invalid Oh store profile input."); + } + if (input.profileKind === "working" && (capabilities.operationReplication || !capabilities.wholeSpacePurge)) { + throw new OhProfileError("A working profile must disable operation replication and permit whole-space purge."); + } + if (input.profileKind === "canonical" && capabilities.wholeSpacePurge) { + throw new OhProfileError("A canonical profile cannot permit whole-space purge."); + } + const payload = { + applicationProfileSha256, + capabilities: Object.freeze(capabilities), + profileId, + profileKind: input.profileKind, + v: 1 + }; + return Object.freeze({ ...payload, profileSha256: canonicalSha256(payload) }); +} +function parseOhStoreProfileV1(value) { + if (!isPlainRecord(value) || !Object.hasOwn(value, "profileSha256")) + return null; + const digest = parseSha256Hex(value.profileSha256); + const { profileSha256: _profileSha256, ...input } = value; + try { + const created = createOhStoreProfileV1(input); + return digest !== null && created.profileSha256 === digest ? created : null; + } catch { + return null; + } +} +function createOhStoreBindingV1(input) { + const profile = parseOhStoreProfileV1(input.profile); + const realmId = safeCode(input.realmId); + const spaceId = safeCode(input.spaceId); + if (input.v !== 1 || profile === null || realmId === null || spaceId === null) { + throw new TypeError("Invalid Oh store binding input."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + profile, + realmId, + spaceId, + v: 1 + }; + return Object.freeze({ ...payload, bindingSha256: canonicalSha256(payload) }); +} +function parseOhStoreBindingV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "contractSha256", + "profile", + "realmId", + "spaceId", + "v" + ]) || value.v !== 1 || value.contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const profile = parseOhStoreProfileV1(value.profile); + try { + if (bindingSha256 === null || profile === null) + return null; + const created = createOhStoreBindingV1({ + profile, + realmId: value.realmId, + spaceId: value.spaceId, + v: 1 + }); + return created.bindingSha256 === bindingSha256 ? created : null; + } catch { + return null; + } +} +function sortedRecords(records) { + return [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); +} +function verifyDependencies(records) { + for (const record of records.values()) { + for (const dependency of record.dependencies) { + if (!records.has(dependency)) + throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`); + } + } +} +function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + const parsedSpaceId = safeCode(spaceId); + if (parsedSpaceId === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid operation replay input."); + } + const records = new Map; + const operationIds = new Set; + let head = emptyOhHeadV1(); + for (const value of values) { + const operation = parseOhOperationV1(value); + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { + throw new OhIntegrityError("Operation replay chain is broken."); + } + operationIds.add(operation.operationId); + for (const change of operation.changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior?.recordSha256 !== change.priorSha256) { + throw new OhIntegrityError("Replay tombstone does not match its prior record."); + } + records.delete(change.key); + } + } + if (records.size > maximumRecords) + throw new RangeError("Operation replay exceeds its record bound."); + verifyDependencies(records); + const refs = sortedRecords(records.values()).map(knowledgeGraphRecordRefV1); + const recordsSha256 = canonicalSha256(refs); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes: operation.changes, + operationId: operation.operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: operation.sequence + }); + if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) { + throw new OhIntegrityError("Replay does not reproduce an operation head."); + } + head = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + } + return { head, records: sortedRecords(records.values()), v: 1 }; +} +function transitionOhSnapshotV1(input) { + const actorId = safeCode(input.actorId); + const operationId = safeCode(input.operationId); + const spaceId = safeCode(input.spaceId); + const instant = parseCanonicalInstantV1(input.instant); + const changes = canonicalKnowledgeGraphChangesV1(input.changes); + if (actorId === null || operationId === null || spaceId === null || instant === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid graph transition input."); + } + const head = parseOhHeadV1(input.snapshot.head); + if (input.snapshot.v !== 1 || head === null || !Array.isArray(input.snapshot.records)) { + throw new OhIntegrityError("The transition snapshot is invalid."); + } + const records = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || records.has(record.key)) { + throw new OhIntegrityError("The transition snapshot contains an invalid record."); + } + records.set(record.key, record); + } + verifyDependencies(records); + const priorRecordsSha256 = canonicalSha256(sortedRecords(records.values()).map(knowledgeGraphRecordRefV1)); + if (priorRecordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The transition snapshot does not reproduce its head."); + } + for (const change of changes) { + if (change.kind === "put") + records.set(change.record.key, change.record); + else { + const prior = records.get(change.key); + if (prior === undefined || prior.recordSha256 !== change.priorSha256) { + throw new OhConflictError(`The prior digest for ${change.key} does not match the snapshot.`); + } + records.delete(change.key); + } + } + if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError("Graph transition exceeds its record snapshot limit."); + } + verifyDependencies(records); + const nextRecords = sortedRecords(records.values()); + const recordsSha256 = canonicalSha256(nextRecords.map(knowledgeGraphRecordRefV1)); + const graphRevisionSha256 = graphRevisionSha256V1({ + changes, + operationId, + parentGraphRevisionSha256: head.graphRevisionSha256, + recordsSha256, + revision: head.sequence + 1 + }); + const operation = createOhOperationV1({ + actorId, + changes, + contractId: OH_CONTRACT_MANIFEST_V1.contractId, + graphRevisionSha256, + instant, + operationId, + parentOperationSha256: head.operationSha256, + recordsSha256, + sequence: head.sequence + 1, + spaceId, + v: 1 + }); + const nextHead = { + generation: operation.sequence, + graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256, + sequence: operation.sequence, + v: 1 + }; + return { operation, snapshot: { head: nextHead, records: nextRecords, v: 1 } }; +} +function normalizeRoots(values) { + if (!Array.isArray(values) || values.length < 1 || values.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new RangeError(`A dependency closure needs 1 through ${OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots} roots.`); + } + const roots = values.map((value) => safeCode(value, 512)); + if (roots.some((value) => value === null)) + throw new TypeError("Invalid dependency closure root."); + const sorted = [...roots].sort(); + if (sorted.some((value, index) => index > 0 && sorted[index - 1] === value)) { + throw new TypeError("Dependency closure roots must be unique."); + } + return sorted; +} +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { + const selected = new Map; + const pending = [...roots]; + let selectedBytes = 0; + while (pending.length > 0) { + const key = pending.pop(); + if (selected.has(key)) + continue; + const record = available.get(key); + if (record === undefined) + throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + selected.set(key, record); + if (selected.size > maximumRecords) + throw new RangeError("Dependency closure exceeds its record bound."); + pending.push(...record.dependencies); + } + return sortedRecords(selected.values()); +} +function createOhDependencyClosureV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const head = parseOhHeadV1(input.snapshot.head); + const maximumRecords = input.maximumRecords ?? OH_DEPENDENCY_CLOSURE_LIMITS_V1.records; + if (binding === null || head === null || !Number.isSafeInteger(maximumRecords) || maximumRecords < 1 || maximumRecords > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) { + throw new TypeError("Invalid dependency closure input."); + } + const roots = normalizeRoots(input.roots); + const available = new Map; + for (const value of input.snapshot.records) { + const record = parseKnowledgeGraphRecordV1(value); + if (record === null || available.has(record.key)) + throw new OhIntegrityError("Snapshot contains an invalid record."); + available.set(record.key, record); + } + const recordsSha256 = canonicalSha256(sortedRecords(available.values()).map(knowledgeGraphRecordRefV1)); + if (recordsSha256 !== head.recordsSha256) + throw new OhIntegrityError("Snapshot records do not reproduce its head."); + const records = closureRecords(available, roots, maximumRecords); + const payload = { binding, head, records, roots, v: 1 }; + const closure = { ...payload, closureSha256: canonicalSha256(payload) }; + if (Buffer.byteLength(canonicalJson(closure), "utf8") > OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes) { + throw new RangeError("Dependency closure exceeds its canonical byte bound."); + } + return Object.freeze(closure); +} +function parseOhDependencyClosureV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "binding", + "closureSha256", + "head", + "records", + "roots", + "v" + ]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.records) + return null; + const binding = parseOhStoreBindingV1(value.binding); + const head = parseOhHeadV1(value.head); + const closureSha256 = parseSha256Hex(value.closureSha256); + if (binding === null || head === null || closureSha256 === null) + return null; + const records = new Map; + for (const item of value.records) { + const record = parseKnowledgeGraphRecordV1(item); + if (record === null || records.has(record.key)) + return null; + records.set(record.key, record); + } + try { + const roots = normalizeRoots(value.roots); + if (canonicalJson(roots) !== canonicalJson(value.roots)) + return null; + const exact = closureRecords(records, roots, OH_DEPENDENCY_CLOSURE_LIMITS_V1.records); + if (canonicalJson(exact) !== canonicalJson(value.records)) + return null; + const payload = { binding, head, records: exact, roots, v: 1 }; + const parsed = { ...payload, closureSha256 }; + return canonicalSha256(payload) === closureSha256 && Buffer.byteLength(canonicalJson(parsed), "utf8") <= OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes ? Object.freeze(parsed) : null; + } catch { + return null; + } +} +function verifyOhDependencyClosureV1(value) { + const closure = parseOhDependencyClosureV1(value); + return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; +} +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} +function createOhSpacePurgeReceiptV1(input) { + const binding = parseOhStoreBindingV1(input.binding); + const priorHead = parseOhHeadV1(input.priorHead); + const purgedAt = parseCanonicalInstantV1(input.purgedAt); + if (binding === null || priorHead === null || purgedAt === null || binding.profile.profileKind !== "working" || !binding.profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Only a bound working realm can produce a purge receipt."); + } + const payload = { + bindingSha256: binding.bindingSha256, + priorHead, + purgedAt, + spaceId: binding.spaceId, + v: 1 + }; + return Object.freeze({ ...payload, receiptSha256: canonicalSha256(payload) }); +} +function parseOhSpacePurgeReceiptV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "bindingSha256", + "priorHead", + "purgedAt", + "receiptSha256", + "spaceId", + "v" + ]) || value.v !== 1) + return null; + const bindingSha256 = parseSha256Hex(value.bindingSha256); + const priorHead = parseOhHeadV1(value.priorHead); + const purgedAt = parseCanonicalInstantV1(value.purgedAt); + const receiptSha256 = parseSha256Hex(value.receiptSha256); + const spaceId = safeCode(value.spaceId); + if (bindingSha256 === null || priorHead === null || purgedAt === null || receiptSha256 === null || spaceId === null) + return null; + const payload = { bindingSha256, priorHead, purgedAt, spaceId, v: 1 }; + return canonicalSha256(payload) === receiptSha256 ? Object.freeze({ ...payload, receiptSha256 }) : null; +} + +class OhSemanticBundleIngressV1 { + #codecs; + #store; + constructor(store, codecs) { + this.#store = store; + this.#codecs = codecs.seal(); + } + async commit(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "actorId", + "expectedHead", + "instant", + "operationId", + "puts", + "tombstones", + "v" + ]) || value.v !== 1 || !Array.isArray(value.puts) || !Array.isArray(value.tombstones) || value.puts.length + value.tombstones.length < 1 || value.puts.length + value.tombstones.length > OH_GRAPH_LIMITS_V1.changesPerOperation) { + throw new TypeError("Invalid semantic bundle."); + } + const actorId = safeCode(value.actorId); + const operationId = safeCode(value.operationId); + const expected = value.expectedHead; + const instant = value.instant === null ? undefined : parseCanonicalInstantV1(value.instant); + if (actorId === null || operationId === null || !isPlainRecord(expected) || !hasExactKeys(expected, ["generation", "operationSha256"]) || !Number.isSafeInteger(expected.generation) || expected.generation < 0 || expected.operationSha256 !== null && parseSha256Hex(expected.operationSha256) === null || expected.generation === 0 !== (expected.operationSha256 === null) || value.instant !== null && instant === null) + throw new TypeError("Invalid semantic bundle identity."); + const changes = []; + for (const item of value.puts) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "v", "value"]) || item.v !== 1 || !Array.isArray(item.dependencies) || !OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === item.kind)) { + throw new TypeError("Invalid semantic bundle put."); + } + const parsed = this.#codecs.parseRequired(item.kind, item.value); + if (parsed === null) + throw new TypeError(`The ${String(item.kind)} codec rejected a semantic value.`); + const record = createKnowledgeGraphRecordV1({ + dependencies: item.dependencies, + key: item.key, + kind: item.kind, + v: 1, + value: parsed + }); + changes.push({ kind: "put", record, v: 1 }); + } + for (const item of value.tombstones) { + if (!isPlainRecord(item) || !hasExactKeys(item, ["key", "priorSha256", "v"]) || item.v !== 1) { + throw new TypeError("Invalid semantic bundle tombstone."); + } + const priorSha256 = parseSha256Hex(item.priorSha256); + if (typeof item.key !== "string" || priorSha256 === null) + throw new TypeError("Invalid semantic bundle tombstone."); + changes.push({ key: item.key, kind: "tombstone", priorSha256, v: 1 }); + } + const canonical = canonicalKnowledgeGraphChangesV1(changes); + return await this.#store.commit({ + actorId, + changes: canonical, + expectedHead: { + generation: expected.generation, + operationSha256: expected.operationSha256 + }, + ...typeof instant === "string" ? { instant } : {}, + operationId + }); + } +} + +// src/memory.ts +import { randomBytes as randomBytes2 } from "node:crypto"; + +// src/projection.ts +var OH_PROJECTION_FORMAT_VERSION_V1 = 1; +var OH_PROJECTION_SEMANTICS_V1 = "oh.projection.positive-datalog.v1"; +var OH_PROJECTION_INTERNAL_ENGINE_V1 = "oh.naive.positive.v1"; +var OH_PROJECTION_LIMITS_V1 = Object.freeze({ + arity: 32, + atomBytes: 16 * 1024, + derivedTuples: 262144, + facts: 262144, + literalsPerRule: 64, + proofDepth: 128, + proofNodes: 4096, + queryLiterals: 64, + queryMatches: 262144, + queryResults: 65536, + relations: 4096, + resultBytes: 16 * 1024 * 1024, + rounds: 1024, + rules: 1024, + sourcesPerFact: 64, + totalProofNodes: 65536, + variables: 256, + workUnits: 16777216 +}); +var recordFactExtractorPayloadV1 = { + factPackId: "oh.record-facts", + factPackRevision: 1, + relations: ["oh.dependency", "oh.record"], + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 +}; +var OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1 = Object.freeze({ + ...recordFactExtractorPayloadV1, + extractorSha256: canonicalSha256(recordFactExtractorPayloadV1) +}); +function nonnegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} +function positiveInteger(value, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : null; +} +function projectionName(value, maximumLength = 128) { + return safeCode(value, maximumLength); +} +function compareCanonical(left, right) { + const leftKey = canonicalJson(left); + const rightKey = canonicalJson(right); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; +} +function compareProjectionFacts(left, right) { + return compareCanonical([left.relation, left.tuple], [right.relation, right.tuple]); +} +var INVALID_PROJECTION_ATOM = Symbol("invalid-projection-atom"); +function atom(value) { + if (value !== null && typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string") { + return INVALID_PROJECTION_ATOM; + } + try { + const encoded = canonicalJson(value); + return utf8ByteLength(encoded) <= OH_PROJECTION_LIMITS_V1.atomBytes ? value : INVALID_PROJECTION_ATOM; + } catch { + return INVALID_PROJECTION_ATOM; + } +} +function tuple(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > OH_PROJECTION_LIMITS_V1.arity) + return null; + const parsed = value.map(atom); + return parsed.some((item) => item === INVALID_PROJECTION_ATOM) ? null : parsed; +} +function parseRecordRef(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["dependencies", "key", "kind", "sha256", "v"]) || value.v !== 1 || !Array.isArray(value.dependencies)) + return null; + const key = safeCode(value.key, 512); + const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === value.kind); + const sha256 = parseSha256Hex(value.sha256); + const dependencies = value.dependencies.map((dependency) => safeCode(dependency, 512)); + if (key === null || kind === undefined || sha256 === null || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || dependencies.some((dependency) => dependency === null) || !orderedUnique(dependencies, String) || dependencies.includes(key)) + return null; + return { dependencies, key, kind, sha256, v: 1 }; +} +function createOhProjectionSnapshotV1(input) { + const spaceId = projectionName(input.spaceId); + const generation = nonnegativeInteger(input.head.generation); + const sequence = nonnegativeInteger(input.head.sequence); + const operationSha256 = input.head.operationSha256 === null ? null : parseSha256Hex(input.head.operationSha256); + const graphRevisionSha256 = input.head.graphRevisionSha256 === null ? null : parseSha256Hex(input.head.graphRevisionSha256); + const declaredRecordsSha256 = parseSha256Hex(input.head.recordsSha256); + if (spaceId === null || generation === null || sequence === null || generation !== sequence || input.head.operationSha256 !== null && operationSha256 === null || input.head.graphRevisionSha256 !== null && graphRevisionSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || declaredRecordsSha256 === null || input.records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new TypeError("Invalid projection snapshot head."); + } + const records = input.records.map(parseKnowledgeGraphRecordV1); + if (records.some((record) => record === null)) + throw new TypeError("Invalid record in projection snapshot."); + const recordRefs = sortUnique(records.map(knowledgeGraphRecordRefV1), (reference) => reference.key); + const recordsSha256 = canonicalSha256(recordRefs); + if (recordsSha256 !== declaredRecordsSha256) { + throw new TypeError("Projection snapshot records do not reproduce the declared head."); + } + const keys = new Set(recordRefs.map((reference) => reference.key)); + if (recordRefs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) { + throw new TypeError("Projection snapshot has a missing record dependency."); + } + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return { ...payload, snapshotSha256: canonicalSha256(payload) }; +} +function parseOhProjectionSnapshotV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "generation", + "graphRevisionSha256", + "operationSha256", + "recordRefs", + "recordsSha256", + "sequence", + "snapshotSha256", + "spaceId", + "v" + ]) || value.v !== 1 || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const generation = nonnegativeInteger(value.generation); + const sequence = nonnegativeInteger(value.sequence); + const graphRevisionSha256 = value.graphRevisionSha256 === null ? null : parseSha256Hex(value.graphRevisionSha256); + const operationSha256 = value.operationSha256 === null ? null : parseSha256Hex(value.operationSha256); + const recordsSha256 = parseSha256Hex(value.recordsSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + const spaceId = projectionName(value.spaceId); + const recordRefs = value.recordRefs.map(parseRecordRef); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || generation === null || sequence === null || generation !== sequence || spaceId === null || recordsSha256 === null || snapshotSha256 === null || value.graphRevisionSha256 !== null && graphRevisionSha256 === null || value.operationSha256 !== null && operationSha256 === null || sequence === 0 !== (operationSha256 === null) || sequence === 0 !== (graphRevisionSha256 === null) || recordRefs.some((reference) => reference === null)) + return null; + const refs = recordRefs; + if (!orderedUnique(refs, (reference) => reference.key) || canonicalSha256(refs) !== recordsSha256) + return null; + const keys = new Set(refs.map((reference) => reference.key)); + if (refs.some((reference) => reference.dependencies.some((dependency) => !keys.has(dependency)))) + return null; + const payload = { + contractSha256, + generation, + graphRevisionSha256, + operationSha256, + recordRefs: refs, + recordsSha256, + sequence, + spaceId, + v: 1 + }; + return canonicalSha256(payload) === snapshotSha256 ? { ...payload, snapshotSha256 } : null; +} +function createOhProjectionFactV1(input) { + const relation = projectionName(input.relation); + const parsedTuple = tuple(input.tuple); + if (relation === null || parsedTuple === null || input.sources.length < 1 || input.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) { + throw new TypeError("Invalid projection fact."); + } + const sources = input.sources.map((source) => { + if (!isPlainRecord(source) || !hasExactKeys(source, ["key", "recordSha256", "v"]) || source.v !== 1) { + throw new TypeError("Invalid projection fact source."); + } + const key = safeCode(source.key, 512); + const recordSha256 = parseSha256Hex(source.recordSha256); + if (key === null || recordSha256 === null) + throw new TypeError("Invalid projection fact source."); + return { key, recordSha256, v: 1 }; + }).sort(compareCanonical); + if (!orderedUnique(sources, (source) => source.key)) { + throw new TypeError("Projection fact sources must have unique record keys."); + } + const payload = { relation, sources, tuple: parsedTuple, v: 1 }; + return { ...payload, factSha256: canonicalSha256(payload) }; +} +function parseOhProjectionFactV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["factSha256", "relation", "sources", "tuple", "v"]) || value.v !== 1 || !Array.isArray(value.sources) || !Array.isArray(value.tuple)) + return null; + const factSha256 = parseSha256Hex(value.factSha256); + try { + const fact = createOhProjectionFactV1({ + relation: value.relation, + sources: value.sources, + tuple: value.tuple + }); + return factSha256 !== null && fact.factSha256 === factSha256 ? fact : null; + } catch { + return null; + } +} +function mergeProjectionFacts(facts) { + const grouped = new Map; + for (const fact of facts) { + const identity = canonicalJson([fact.relation, fact.tuple]); + let group = grouped.get(identity); + if (group === undefined) { + group = { relation: fact.relation, sources: new Map, tuple: fact.tuple }; + grouped.set(identity, group); + } + for (const source of fact.sources) { + const existing = group.sources.get(source.key); + if (existing !== undefined && existing.recordSha256 !== source.recordSha256) { + throw new TypeError("One fact source key is bound to multiple record digests."); + } + group.sources.set(source.key, source); + } + } + return [...grouped.values()].map((group) => createOhProjectionFactV1({ + relation: group.relation, + sources: [...group.sources.values()], + tuple: group.tuple + })).sort(compareProjectionFacts); +} +function createOhProjectionDatasetV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const extractorSha256 = parseSha256Hex(input.extractorSha256); + const factPackId = projectionName(input.factPackId); + const factPackRevision = positiveInteger(input.factPackRevision); + if (snapshot === null || extractorSha256 === null || factPackId === null || factPackRevision === null || input.facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new TypeError("Invalid projection dataset."); + const parsedFacts = input.facts.map(parseOhProjectionFactV1); + if (parsedFacts.some((fact) => fact === null)) + throw new TypeError("Invalid fact in projection dataset."); + const facts = mergeProjectionFacts(parsedFacts); + if (facts.length > OH_PROJECTION_LIMITS_V1.facts) + throw new RangeError("Projection dataset has too many facts."); + const refs = new Map(snapshot.recordRefs.map((reference) => [reference.key, reference.sha256])); + for (const fact of facts) { + for (const source of fact.sources) { + if (refs.get(source.key) !== source.recordSha256) { + throw new TypeError("Projection fact source is not present at the exact input snapshot."); + } + } + } + const factPackPayload = { + extractorSha256, + factPackId, + factPackRevision, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + const factPackSha256 = canonicalSha256(factPackPayload); + const factsSha256 = canonicalSha256(facts); + const payload = { + extractorSha256, + factPackId, + factPackRevision, + factPackSha256, + facts, + factsSha256, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, datasetSha256: canonicalSha256(payload) }; +} +function parseOhProjectionDatasetV1(value, snapshot) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "datasetSha256", + "extractorSha256", + "factPackId", + "factPackRevision", + "factPackSha256", + "facts", + "factsSha256", + "snapshotSha256", + "v" + ]) || value.v !== 1 || !Array.isArray(value.facts)) + return null; + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const declaredFactPackSha256 = parseSha256Hex(value.factPackSha256); + const declaredFactsSha256 = parseSha256Hex(value.factsSha256); + try { + const dataset = createOhProjectionDatasetV1({ + extractorSha256: value.extractorSha256, + factPackId: value.factPackId, + factPackRevision: value.factPackRevision, + facts: value.facts, + snapshot + }); + return datasetSha256 !== null && declaredFactPackSha256 === dataset.factPackSha256 && declaredFactsSha256 === dataset.factsSha256 && value.snapshotSha256 === dataset.snapshotSha256 && dataset.datasetSha256 === datasetSha256 ? dataset : null; + } catch { + return null; + } +} +function ohProjectionVariableV1(name) { + const parsed = projectionName(name); + if (parsed === null) + throw new TypeError("Invalid projection variable name."); + return { kind: "variable", name: parsed, v: 1 }; +} +function ohProjectionConstantV1(value) { + const parsed = atom(value); + if (parsed === INVALID_PROJECTION_ATOM) + throw new TypeError("Invalid projection constant."); + return { kind: "constant", v: 1, value: parsed }; +} +function createOhProjectionLiteralV1(input) { + const relation = projectionName(input.relation); + if (relation === null || input.terms.length < 1 || input.terms.length > OH_PROJECTION_LIMITS_V1.arity) { + throw new TypeError("Invalid projection literal."); + } + const terms = input.terms.map((term) => parseOhProjectionTermV1(term)); + if (terms.some((term) => term === null)) + throw new TypeError("Invalid term in projection literal."); + return { relation, terms, v: 1 }; +} +function parseOhProjectionTermV1(value) { + if (!isPlainRecord(value) || value.v !== 1) + return null; + if (value.kind === "variable" && hasExactKeys(value, ["kind", "name", "v"])) { + const name = projectionName(value.name); + return name === null ? null : { kind: "variable", name, v: 1 }; + } + if (value.kind === "constant" && hasExactKeys(value, ["kind", "v", "value"])) { + const parsed = atom(value.value); + return parsed === INVALID_PROJECTION_ATOM ? null : { kind: "constant", v: 1, value: parsed }; + } + return null; +} +function parseOhProjectionLiteralV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["relation", "terms", "v"]) || value.v !== 1 || !Array.isArray(value.terms)) + return null; + try { + return createOhProjectionLiteralV1({ + relation: value.relation, + terms: value.terms + }); + } catch { + return null; + } +} +function literalVariables(literal) { + return literal.terms.flatMap((term) => term.kind === "variable" ? [term.name] : []); +} +function createOhProjectionRuleV1(input) { + const ruleId = projectionName(input.ruleId); + const head = parseOhProjectionLiteralV1(input.head); + if (ruleId === null || head === null || input.body.length < 1 || input.body.length > OH_PROJECTION_LIMITS_V1.literalsPerRule) + throw new TypeError("Invalid projection rule."); + const body = input.body.map(parseOhProjectionLiteralV1); + if (body.some((literal) => literal === null)) + throw new TypeError("Invalid body literal in projection rule."); + const bound = new Set(body.flatMap(literalVariables)); + if (literalVariables(head).some((variable) => !bound.has(variable)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projection rule head variable must be bound in its body."); + } + const payload = { body, head, ruleId, v: 1 }; + return { ...payload, ruleSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRuleV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["body", "head", "ruleId", "ruleSha256", "v"]) || value.v !== 1 || !Array.isArray(value.body)) + return null; + const ruleSha256 = parseSha256Hex(value.ruleSha256); + try { + const rule = createOhProjectionRuleV1({ + body: value.body, + head: value.head, + ruleId: value.ruleId + }); + return ruleSha256 !== null && rule.ruleSha256 === ruleSha256 ? rule : null; + } catch { + return null; + } +} +function createOhProjectionRulePackV1(input) { + const rulePackId = projectionName(input.rulePackId); + const rulePackRevision = positiveInteger(input.rulePackRevision); + if (rulePackId === null || rulePackRevision === null || input.rules.length < 1 || input.rules.length > OH_PROJECTION_LIMITS_V1.rules) + throw new TypeError("Invalid projection rule pack."); + const parsedRules = input.rules.map(parseOhProjectionRuleV1); + if (parsedRules.some((rule) => rule === null)) + throw new TypeError("Invalid rule in projection rule pack."); + const rules = sortUnique(parsedRules, (rule) => rule.ruleId); + const rulesSha256 = canonicalSha256(rules); + const payload = { + rulePackId, + rulePackRevision, + rules, + rulesSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 + }; + return { ...payload, rulePackSha256: canonicalSha256(payload) }; +} +function parseOhProjectionRulePackV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "rulePackId", + "rulePackRevision", + "rulePackSha256", + "rules", + "rulesSha256", + "semantics", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1 || !Array.isArray(value.rules)) + return null; + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const rulesSha256 = parseSha256Hex(value.rulesSha256); + try { + const pack = createOhProjectionRulePackV1({ + rulePackId: value.rulePackId, + rulePackRevision: value.rulePackRevision, + rules: value.rules + }); + return rulePackSha256 === pack.rulePackSha256 && rulesSha256 === pack.rulesSha256 ? pack : null; + } catch { + return null; + } +} +function createOhProjectionQueryV1(input) { + const queryId = projectionName(input.queryId); + const limit = positiveInteger(input.limit ?? 1000, OH_PROJECTION_LIMITS_V1.queryResults); + if (queryId === null || limit === null || input.find.length < 1 || input.find.length > OH_PROJECTION_LIMITS_V1.arity || input.where.length < 1 || input.where.length > OH_PROJECTION_LIMITS_V1.queryLiterals) + throw new TypeError("Invalid projection query."); + const find = input.find.map((name) => projectionName(name)); + const where = input.where.map(parseOhProjectionLiteralV1); + if (find.some((name) => name === null) || !orderedUnique([...find].sort(), String) || where.some((literal) => literal === null)) + throw new TypeError("Invalid projection query variables or literals."); + const bound = new Set(where.flatMap(literalVariables)); + if (find.some((name) => !bound.has(name)) || bound.size > OH_PROJECTION_LIMITS_V1.variables) { + throw new TypeError("Every projected query variable must be bound in the query body."); + } + const payload = { + find, + limit, + queryId, + where, + v: 1 + }; + return { ...payload, querySha256: canonicalSha256(payload) }; +} +function parseOhProjectionQueryV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["find", "limit", "queryId", "querySha256", "where", "v"]) || value.v !== 1 || !Array.isArray(value.find) || !Array.isArray(value.where)) + return null; + const querySha256 = parseSha256Hex(value.querySha256); + try { + const query = createOhProjectionQueryV1({ + find: value.find, + limit: value.limit, + queryId: value.queryId, + where: value.where + }); + return querySha256 !== null && query.querySha256 === querySha256 ? query : null; + } catch { + return null; + } +} +function createOhProjectionIdentityV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const query = parseOhProjectionQueryV1(input.query); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + if (snapshot === null || dataset === null || query === null || rulePack === null) { + throw new TypeError("Invalid projection identity input."); + } + const engine = safeCode(input.engine ?? OH_PROJECTION_INTERNAL_ENGINE_V1, 256); + if (engine === null) + throw new TypeError("Invalid projection engine identity."); + const evaluation = { ...resolveEvaluationOptions(input.options ?? {}), v: 1 }; + const payload = { + contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, + datasetSha256: dataset.datasetSha256, + engineSha256: canonicalSha256({ engine, v: 1 }), + evaluationSha256: canonicalSha256(evaluation), + querySha256: query.querySha256, + rulePackSha256: rulePack.rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256: snapshot.snapshotSha256, + v: 1 + }; + return { ...payload, projectionSha256: canonicalSha256(payload) }; +} +function parseOhProjectionIdentityV1(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "contractSha256", + "datasetSha256", + "engineSha256", + "evaluationSha256", + "projectionSha256", + "querySha256", + "rulePackSha256", + "semantics", + "snapshotSha256", + "v" + ]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1) + return null; + const contractSha256 = parseSha256Hex(value.contractSha256); + const datasetSha256 = parseSha256Hex(value.datasetSha256); + const engineSha256 = parseSha256Hex(value.engineSha256); + const evaluationSha256 = parseSha256Hex(value.evaluationSha256); + const projectionSha256 = parseSha256Hex(value.projectionSha256); + const querySha256 = parseSha256Hex(value.querySha256); + const rulePackSha256 = parseSha256Hex(value.rulePackSha256); + const snapshotSha256 = parseSha256Hex(value.snapshotSha256); + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || engineSha256 === null || evaluationSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) + return null; + const payload = { + contractSha256, + datasetSha256, + engineSha256, + evaluationSha256, + querySha256, + rulePackSha256, + semantics: OH_PROJECTION_SEMANTICS_V1, + snapshotSha256, + v: 1 + }; + return canonicalSha256(payload) === projectionSha256 ? { ...payload, projectionSha256 } : null; +} +function invalidationForOhProjectionV1(previous, next) { + const parsedPrevious = parseOhProjectionIdentityV1(previous); + const parsedNext = parseOhProjectionIdentityV1(next); + if (parsedPrevious === null || parsedNext === null) + throw new TypeError("Invalid projection identity."); + if (parsedPrevious.projectionSha256 === parsedNext.projectionSha256) + return { kind: "reusable", v: 1 }; + const reasons = []; + if (parsedPrevious.snapshotSha256 !== parsedNext.snapshotSha256) + reasons.push("snapshot-changed"); + if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) + reasons.push("dataset-changed"); + if (parsedPrevious.engineSha256 !== parsedNext.engineSha256) + reasons.push("engine-changed"); + if (parsedPrevious.evaluationSha256 !== parsedNext.evaluationSha256) + reasons.push("evaluation-changed"); + if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) + reasons.push("rule-pack-changed"); + if (parsedPrevious.querySha256 !== parsedNext.querySha256) + reasons.push("query-changed"); + return { kind: "full-rebuild", reasons, v: 1 }; +} +function tupleKey(value) { + return canonicalJson(value); +} +function referenceKey(reference) { + return canonicalJson([reference.relation, reference.tuple]); +} +function relationTuples(relations, relation) { + return [...relations.get(relation)?.values() ?? []].sort((left, right) => compareCanonical(left.tuple, right.tuple)); +} +function setArity(arities, relation, arity) { + const existing = arities.get(relation); + if (existing !== undefined && existing !== arity) { + throw new TypeError(`Projection relation ${relation} is used with conflicting arities.`); + } + arities.set(relation, arity); + if (arities.size > OH_PROJECTION_LIMITS_V1.relations) + throw new RangeError("Projection uses too many relations."); +} +function validateProgramArities(dataset, rulePack, query) { + const arities = new Map; + for (const fact of dataset.facts) + setArity(arities, fact.relation, fact.tuple.length); + for (const rule of rulePack.rules) { + setArity(arities, rule.head.relation, rule.head.terms.length); + for (const literal of rule.body) + setArity(arities, literal.relation, literal.terms.length); + } + for (const literal of query.where) + setArity(arities, literal.relation, literal.terms.length); +} +function sameAtom(left, right) { + return left === right; +} +function unifyLiteral(literal, state, binding) { + const next = new Map(binding); + for (let index = 0;index < literal.terms.length; index += 1) { + const term = literal.terms[index]; + const value = state.tuple[index]; + if (term.kind === "constant") { + if (!sameAtom(term.value, value)) + return null; + continue; + } + if (next.has(term.name)) { + if (!sameAtom(next.get(term.name), value)) + return null; + } else + next.set(term.name, value); + } + return next; +} +function consumeWorkUnit(budget) { + if (budget.units >= budget.maximum) + throw new RangeError("Projection exceeds its work-unit bound."); + budget.units += 1; +} +function matchBody(relations, body, maximumMatches, work) { + let matches = [{ binding: new Map, premises: [] }]; + for (const literal of body) { + const next = []; + const candidates = relationTuples(relations, literal.relation); + for (const match of matches) { + for (const candidate of candidates) { + consumeWorkUnit(work); + const binding = unifyLiteral(literal, candidate, match.binding); + if (binding === null) + continue; + next.push({ binding, premises: [...match.premises, { + relation: literal.relation, + tuple: candidate.tuple + }] }); + if (next.length > maximumMatches) + throw new RangeError("Projection join exceeds its match bound."); + } + } + matches = next; + if (matches.length === 0) + break; + } + return matches; +} +function instantiateHead(head, binding) { + return head.terms.map((term) => term.kind === "constant" ? term.value : binding.get(term.name)); +} +function canonicalWitness(witness) { + if (witness.kind === "fact") + return canonicalJson(witness); + return canonicalJson({ kind: witness.kind, premises: witness.premises, ruleSha256: witness.rule.ruleSha256 }); +} +function materializeNaive(input) { + const relations = new Map; + for (const fact of input.dataset.facts) { + let relation = relations.get(fact.relation); + if (relation === undefined) { + relation = new Map; + relations.set(fact.relation, relation); + } + relation.set(tupleKey(fact.tuple), { tuple: fact.tuple, witness: { kind: "fact", sources: fact.sources } }); + } + let derivedFacts = 0; + let rounds = 0; + while (true) { + const candidates = new Map; + for (const rule of input.rulePack.rules) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches, input.work)) { + const derivedTuple = instantiateHead(rule.head, match.binding); + const relation = relations.get(rule.head.relation); + const key = tupleKey(derivedTuple); + if (relation?.has(key) === true) + continue; + const state = { + tuple: derivedTuple, + witness: { kind: "derived", premises: match.premises, rule } + }; + const identity = referenceKey({ relation: rule.head.relation, tuple: derivedTuple }); + const existing = candidates.get(identity); + if (existing === undefined || canonicalWitness(state.witness) < canonicalWitness(existing.state.witness)) { + candidates.set(identity, { relation: rule.head.relation, state }); + } + } + } + if (candidates.size === 0) + break; + if (rounds >= input.maximumRounds) + throw new RangeError("Projection exceeds its evaluation round bound."); + if (derivedFacts + candidates.size > input.maximumDerivedTuples) { + throw new RangeError("Projection exceeds its derived tuple bound."); + } + const ordered = [...candidates.values()].sort((left, right) => compareCanonical([left.relation, left.state.tuple], [right.relation, right.state.tuple])); + for (const candidate of ordered) { + let relation = relations.get(candidate.relation); + if (relation === undefined) { + relation = new Map; + relations.set(candidate.relation, relation); + } + relation.set(tupleKey(candidate.state.tuple), candidate.state); + } + derivedFacts += candidates.size; + rounds += 1; + } + return { baseFacts: input.dataset.facts.length, derivedFacts, relations, rounds }; +} +function boundedOption(value, fallback, maximum, label) { + const parsed = positiveInteger(value ?? fallback, maximum); + if (parsed === null) + throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`); + return parsed; +} +function resolveEvaluationOptions(options) { + const resolved = { + maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), + maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), + maximumProofNodes: boundedOption(options.maximumProofNodes, 1024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), + maximumResultBytes: boundedOption(options.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, "maximumResultBytes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds"), + maximumTotalProofNodes: boundedOption(options.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, "maximumTotalProofNodes"), + maximumWorkUnits: boundedOption(options.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits, OH_PROJECTION_LIMITS_V1.workUnits, "maximumWorkUnits") + }; + if (resolved.maximumResultBytes < 64 * 1024) { + throw new RangeError("maximumResultBytes must be at least 65536."); + } + return resolved; +} +function reserveResultBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function reserveProofNode(budget, options, envelope) { + if (budget.nodes >= options.maximumProofNodes || budget.result.nodes >= options.maximumTotalProofNodes || !reserveResultBytes(budget.result, envelope)) + return false; + budget.nodes += 1; + budget.result.nodes += 1; + return true; +} +function proofForReference(relations, reference, budget, options, depth, visiting) { + if (depth >= options.maximumProofDepth) { + const proof = { + kind: "truncated", + reason: "depth", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; + } + const identity = referenceKey(reference); + if (visiting.has(identity)) { + const proof = { + kind: "truncated", + reason: "cycle", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; + } + const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); + if (state === undefined) + throw new Error("Projection proof references a tuple outside the materialized result."); + if (state.witness.kind === "fact") { + const proof = { + kind: "fact", + relation: reference.relation, + sources: state.witness.sources, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; + } + const envelope = { + kind: "derived", + premises: [], + premisesTruncated: false, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + if (!reserveProofNode(budget, options, envelope)) + return null; + visiting.add(identity); + try { + const premises = []; + let premisesTruncated = false; + for (const premise of state.witness.premises) { + const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); + if (proof === null) { + premisesTruncated = true; + break; + } + premises.push(proof); + } + return { + kind: "derived", + premises, + premisesTruncated, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + } finally { + visiting.delete(identity); + } +} +function proofIsTruncated(proof) { + return proof.kind === "truncated" || proof.kind === "derived" && (proof.premisesTruncated || proof.premises.some(proofIsTruncated)); +} +function reserveProjectionParseBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function parseProjectionFactSource(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["key", "recordSha256", "v"]) || value.v !== 1) { + return null; + } + const key = safeCode(value.key, 512); + const recordSha256 = parseSha256Hex(value.recordSha256); + return key === null || recordSha256 === null ? null : { key, recordSha256, v: 1 }; +} +function parseProjectionProofWithBudget(value, budget, depth) { + if (depth > budget.maximumDepth || budget.nodes >= budget.maximumNodes || !isPlainRecord(value) || value.v !== 1) + return null; + const relation = projectionName(value.relation); + const parsedTuple = tuple(value.tuple); + if (relation === null || parsedTuple === null) + return null; + if (value.kind === "fact") { + if (!hasExactKeys(value, ["kind", "relation", "sources", "tuple", "v"]) || !Array.isArray(value.sources) || value.sources.length < 1 || value.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) + return null; + const sources = value.sources.map(parseProjectionFactSource); + if (sources.some((source) => source === null)) + return null; + const parsedSources = sources; + if (!orderedUnique(parsedSources, (source) => source.key)) + return null; + const proof = { + kind: "fact", + relation, + sources: parsedSources, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind === "truncated") { + if (!hasExactKeys(value, ["kind", "reason", "relation", "tuple", "v"]) || value.reason !== "cycle" && value.reason !== "depth" && value.reason !== "nodes") + return null; + const reason = value.reason; + const proof = { + kind: "truncated", + reason, + relation, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind !== "derived" || !hasExactKeys(value, [ + "kind", + "premises", + "premisesTruncated", + "relation", + "ruleId", + "ruleSha256", + "tuple", + "v" + ]) || !Array.isArray(value.premises) || value.premises.length > OH_PROJECTION_LIMITS_V1.literalsPerRule || typeof value.premisesTruncated !== "boolean") + return null; + const ruleId = projectionName(value.ruleId); + const ruleSha256 = parseSha256Hex(value.ruleSha256); + if (ruleId === null || ruleSha256 === null || !value.premisesTruncated && value.premises.length === 0 || value.premisesTruncated && value.premises.length === OH_PROJECTION_LIMITS_V1.literalsPerRule) { + return null; + } + const skeleton = { + kind: "derived", + premises: [], + premisesTruncated: value.premisesTruncated, + relation, + ruleId, + ruleSha256, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, skeleton)) + return null; + budget.nodes += 1; + const premises = []; + for (const premise of value.premises) { + const parsed = parseProjectionProofWithBudget(premise, budget, depth + 1); + if (parsed === null) + return null; + premises.push(parsed); + } + return { ...skeleton, premises }; +} +function parseOhProjectionProofV1(value) { + try { + const budget = { + bytes: 0, + maximumBytes: OH_PROJECTION_LIMITS_V1.resultBytes, + maximumDepth: OH_PROJECTION_LIMITS_V1.proofDepth, + maximumNodes: OH_PROJECTION_LIMITS_V1.proofNodes, + nodes: 0 + }; + const proof = parseProjectionProofWithBudget(value, budget, 0); + return proof !== null && utf8ByteLength(canonicalJson(proof)) <= budget.maximumBytes ? proof : null; + } catch { + return null; + } +} +function parseProjectionEvaluation(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "maximumDerivedTuples", + "maximumProofDepth", + "maximumProofNodes", + "maximumResultBytes", + "maximumRounds", + "maximumTotalProofNodes", + "maximumWorkUnits", + "v" + ]) || value.v !== 1) + return null; + const maximumDerivedTuples = positiveInteger(value.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples); + const maximumProofDepth = positiveInteger(value.maximumProofDepth, OH_PROJECTION_LIMITS_V1.proofDepth); + const maximumProofNodes = positiveInteger(value.maximumProofNodes, OH_PROJECTION_LIMITS_V1.proofNodes); + const maximumResultBytes = positiveInteger(value.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes); + const maximumRounds = positiveInteger(value.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds); + const maximumTotalProofNodes = positiveInteger(value.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes); + const maximumWorkUnits = positiveInteger(value.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits); + if (maximumDerivedTuples === null || maximumProofDepth === null || maximumProofNodes === null || maximumResultBytes === null || maximumResultBytes < 64 * 1024 || maximumRounds === null || maximumTotalProofNodes === null || maximumWorkUnits === null) + return null; + return { + maximumDerivedTuples, + maximumProofDepth, + maximumProofNodes, + maximumResultBytes, + maximumRounds, + maximumTotalProofNodes, + maximumWorkUnits, + v: 1 + }; +} +function parseProjectionResultRow(value, evaluation, resultBudget) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["proofs", "proofsTruncated", "supportCount", "values", "v"]) || value.v !== 1 || !Array.isArray(value.proofs) || value.proofs.length > OH_PROJECTION_LIMITS_V1.queryLiterals || typeof value.proofsTruncated !== "boolean") + return null; + const values = tuple(value.values); + const supportCount = positiveInteger(value.supportCount, OH_PROJECTION_LIMITS_V1.queryMatches); + if (values === null || supportCount === null || !value.proofsTruncated && value.proofs.length === 0) + return null; + if (!reserveProjectionParseBytes(resultBudget, { + proofs: [], + proofsTruncated: value.proofsTruncated, + supportCount, + values, + v: 1 + })) + return null; + const before = resultBudget.nodes; + resultBudget.maximumNodes = Math.min(resultBudget.maximumNodes, before + evaluation.maximumProofNodes); + const proofs = []; + for (const proof of value.proofs) { + const parsed = parseProjectionProofWithBudget(proof, resultBudget, 0); + if (parsed === null) + return null; + proofs.push(parsed); + } + resultBudget.maximumNodes = evaluation.maximumTotalProofNodes; + const containsTruncation = proofs.some(proofIsTruncated); + if (!value.proofsTruncated && containsTruncation || value.proofsTruncated && proofs.length === OH_PROJECTION_LIMITS_V1.queryLiterals && !containsTruncation) + return null; + return { + nodes: resultBudget.nodes - before, + row: { proofs, proofsTruncated: value.proofsTruncated, supportCount, values, v: 1 } + }; +} +function parseOhProjectionResultV1(value, expectedProjectionSha256) { + try { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "authority", + "cache", + "engine", + "evaluation", + "identity", + "resultSha256", + "rows", + "stats", + "v" + ]) || value.v !== 1 || value.authority !== "derived" || !isPlainRecord(value.cache) || !hasExactKeys(value.cache, ["strategy", "v"]) || value.cache.strategy !== "full-rebuild" || value.cache.v !== 1 || !Array.isArray(value.rows) || value.rows.length > OH_PROJECTION_LIMITS_V1.queryResults || !isPlainRecord(value.stats) || !hasExactKeys(value.stats, [ + "baseFacts", + "derivedFacts", + "proofNodes", + "proofsTruncated", + "queryMatches", + "relations", + "rounds", + "truncated", + "truncationReasons", + "v", + "workUnits" + ]) || value.stats.v !== 1 || !Array.isArray(value.stats.truncationReasons) || typeof value.stats.proofsTruncated !== "boolean" || typeof value.stats.truncated !== "boolean") + return null; + const engine = safeCode(value.engine, 256); + const evaluation = parseProjectionEvaluation(value.evaluation); + const identity = parseOhProjectionIdentityV1(value.identity); + const resultSha256 = parseSha256Hex(value.resultSha256); + const expected = expectedProjectionSha256 === undefined ? undefined : parseSha256Hex(expectedProjectionSha256); + if (engine === null || evaluation === null || identity === null || resultSha256 === null || expectedProjectionSha256 !== undefined && expected === null || expected !== undefined && identity.projectionSha256 !== expected || identity.engineSha256 !== canonicalSha256({ engine, v: 1 }) || identity.evaluationSha256 !== canonicalSha256(evaluation)) + return null; + const baseFacts = nonnegativeInteger(value.stats.baseFacts); + const derivedFacts = nonnegativeInteger(value.stats.derivedFacts); + const proofNodes = nonnegativeInteger(value.stats.proofNodes); + const queryMatches = nonnegativeInteger(value.stats.queryMatches); + const relations = nonnegativeInteger(value.stats.relations); + const rounds = nonnegativeInteger(value.stats.rounds); + const workUnits = nonnegativeInteger(value.stats.workUnits); + if (baseFacts === null || baseFacts > OH_PROJECTION_LIMITS_V1.facts || derivedFacts === null || derivedFacts > evaluation.maximumDerivedTuples || proofNodes === null || proofNodes > evaluation.maximumTotalProofNodes || queryMatches === null || queryMatches > OH_PROJECTION_LIMITS_V1.queryMatches || relations === null || relations > OH_PROJECTION_LIMITS_V1.relations || rounds === null || rounds > evaluation.maximumRounds || workUnits === null || workUnits > evaluation.maximumWorkUnits || relations > baseFacts + derivedFacts || rounds > derivedFacts || rounds === 0 !== (derivedFacts === 0) || queryMatches > workUnits) + return null; + const truncationReasons = value.stats.truncationReasons; + if (truncationReasons.length > 2 || !orderedUnique(truncationReasons, (reason) => reason === "query-limit" ? "0" : reason === "result-bytes" ? "1" : "x") || truncationReasons.some((reason) => reason !== "query-limit" && reason !== "result-bytes") || value.stats.truncated !== truncationReasons.length > 0) + return null; + const budget = { + bytes: 0, + maximumBytes: evaluation.maximumResultBytes, + maximumDepth: evaluation.maximumProofDepth, + maximumNodes: evaluation.maximumTotalProofNodes, + nodes: 0 + }; + const rows = []; + let supportCount = 0; + for (const row of value.rows) { + const parsed = parseProjectionResultRow(row, evaluation, budget); + if (parsed === null) + return null; + rows.push(parsed.row); + supportCount += parsed.row.supportCount; + if (supportCount > queryMatches) + return null; + } + if (!orderedUnique(rows, (row) => canonicalJson(row.values)) || budget.nodes !== proofNodes || value.stats.proofsTruncated !== rows.some((row) => row.proofsTruncated) || (value.stats.truncated ? supportCount >= queryMatches : supportCount !== queryMatches)) + return null; + const reasons = truncationReasons; + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine, + evaluation, + identity, + rows, + stats: { + baseFacts, + derivedFacts, + proofNodes, + proofsTruncated: value.stats.proofsTruncated, + queryMatches, + relations, + rounds, + truncated: value.stats.truncated, + truncationReasons: reasons, + v: 1, + workUnits + }, + v: 1 + }; + const serialized = canonicalJson(payload); + return utf8ByteLength(serialized) <= evaluation.maximumResultBytes && sha256Hex(serialized) === resultSha256 ? { ...payload, resultSha256 } : null; + } catch { + return null; + } +} +function buildProjectionResult(input) { + const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches, input.work); + const byValues = new Map; + for (const match of matches) { + const values = input.query.find.map((name) => match.binding.get(name)); + const key = tupleKey(values); + const existing = byValues.get(key); + if (existing === undefined) + byValues.set(key, { match, supportCount: 1 }); + else + byValues.set(key, { match: compareCanonical(match.premises, existing.match.premises) < 0 ? match : existing.match, supportCount: existing.supportCount + 1 }); + } + const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + const resultBudget = { + bytes: 0, + maximumBytes: input.options.maximumResultBytes - 64 * 1024, + nodes: 0 + }; + const rows = []; + let resultBytesTruncated = false; + for (const [key, support] of ordered.slice(0, input.query.limit)) { + const values = JSON.parse(key); + if (!reserveResultBytes(resultBudget, { + proofs: [], + proofsTruncated: false, + supportCount: support.supportCount, + values, + v: 1 + })) { + resultBytesTruncated = true; + break; + } + const budget = { nodes: 0, result: resultBudget }; + const proofs = []; + for (const premise of support.match.premises) { + const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set); + if (proof === null) + break; + proofs.push(proof); + } + const proofsTruncated = proofs.length !== support.match.premises.length || proofs.some(proofIsTruncated); + rows.push({ proofs, proofsTruncated, supportCount: support.supportCount, values, v: 1 }); + } + const queryLimitTruncated = ordered.length > input.query.limit; + const truncationReasons = [ + ...queryLimitTruncated ? ["query-limit"] : [], + ...resultBytesTruncated ? ["result-bytes"] : [] + ]; + const truncated = truncationReasons.length > 0; + const identity = createOhProjectionIdentityV1({ + dataset: input.dataset, + query: input.query, + engine: input.engine, + options: input.options, + rulePack: input.rulePack, + snapshot: input.snapshot + }); + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine: input.engine, + evaluation: { ...input.options, v: 1 }, + identity, + rows, + stats: { + baseFacts: input.materialized.baseFacts, + derivedFacts: input.materialized.derivedFacts, + proofNodes: resultBudget.nodes, + proofsTruncated: rows.some((row) => row.proofsTruncated), + queryMatches: matches.length, + relations: input.materialized.relations.size, + rounds: input.materialized.rounds, + truncated, + truncationReasons, + v: 1, + workUnits: input.work.units + }, + v: 1 + }; + const serialized = canonicalJson(payload); + if (utf8ByteLength(serialized) > input.options.maximumResultBytes) { + throw new RangeError("Projection result exceeds its canonical byte bound."); + } + return { ...payload, resultSha256: sha256Hex(serialized) }; +} +function evaluateOhProjectionV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + if (snapshot === null || dataset === null || rulePack === null || query === null) { + throw new TypeError("Invalid projection snapshot, dataset, rule pack, or query."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; + const materialized = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack, + work + }); + return buildProjectionResult({ + dataset, + engine: OH_PROJECTION_INTERNAL_ENGINE_V1, + materialized, + options, + query, + rulePack, + snapshot, + work + }); +} +function evaluateOhProjectionWithMaterializerV1(input) { + const snapshot = parseOhProjectionSnapshotV1(input.snapshot); + const dataset = snapshot === null ? null : parseOhProjectionDatasetV1(input.dataset, snapshot); + const rulePack = parseOhProjectionRulePackV1(input.rulePack); + const query = parseOhProjectionQueryV1(input.query); + const engine = safeCode(input.engine, 256); + if (snapshot === null || dataset === null || rulePack === null || query === null || engine === null) { + throw new TypeError("Invalid projection adapter input."); + } + const options = resolveEvaluationOptions(input.options ?? {}); + validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; + const witnessMaterialization = materializeNaive({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + rulePack, + work + }); + const external = input.materialize({ + dataset, + maximumDerivedTuples: options.maximumDerivedTuples, + maximumRounds: options.maximumRounds, + query, + rulePack + }); + const externalCanonical = new Map; + for (const [relationName, tuples] of external.relationFacts) { + const relation = projectionName(relationName); + if (relation === null || tuples.length > OH_PROJECTION_LIMITS_V1.facts + options.maximumDerivedTuples) { + throw new TypeError("Projection adapter returned an invalid relation."); + } + const parsed = tuples.map(tuple); + if (parsed.some((value) => value === null)) + throw new TypeError("Projection adapter returned an invalid tuple."); + const keys = []; + for (const value of parsed) { + if (value === null) + throw new TypeError("Projection adapter returned an invalid tuple."); + keys.push(tupleKey(value)); + } + externalCanonical.set(relation, [...new Set(keys)].sort()); + } + const expectedCanonical = new Map([...witnessMaterialization.relations.entries()].map(([relation, states]) => [relation, [...states.values()].map((state) => tupleKey(state.tuple)).sort()])); + const relationNames = [...new Set([...externalCanonical.keys(), ...expectedCanonical.keys()])].sort(); + for (const relation of relationNames) { + if (canonicalJson(externalCanonical.get(relation) ?? []) !== canonicalJson(expectedCanonical.get(relation) ?? [])) { + throw new Error(`Projection adapter disagrees with Oh semantics for relation ${relation}.`); + } + } + return buildProjectionResult({ + dataset, + engine, + materialized: witnessMaterialization, + options, + query, + rulePack, + snapshot, + work + }); +} +function createOhProjectionRecordFactsV1(records, options = {}) { + if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) + throw new RangeError("Too many records for projection facts."); + const parsedRecords = [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0).map((candidate) => { + const record = parseKnowledgeGraphRecordV1(candidate); + if (record === null) + throw new TypeError("Invalid graph record for projection facts."); + return record; + }); + let projectedFactCount = 0; + for (const record of parsedRecords) { + if (options.includeRecords !== false) + projectedFactCount += 1; + if (options.includeDependencies !== false) + projectedFactCount += record.dependencies.length; + if (projectedFactCount > OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("Structural projection exceeds its fact bound."); + } + } + const facts = []; + for (const record of parsedRecords) { + const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 }]; + if (options.includeRecords !== false) { + facts.push(createOhProjectionFactV1({ + relation: "oh.record", + sources: source, + tuple: [record.key, record.kind, record.recordSha256] + })); + } + if (options.includeDependencies !== false) { + for (const dependency of record.dependencies) { + facts.push(createOhProjectionFactV1({ + relation: "oh.dependency", + sources: source, + tuple: [record.key, dependency] + })); + } + } + } + return facts.sort(compareProjectionFacts); +} +function isOhProjectionRecordKindV1(value) { + return OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.some((kind) => kind === value); +} + +// src/memory.ts +var OH_MEMORY_FORMAT_VERSION_V1 = 1; +var OH_MEMORY_CONFLICT_POLICY_V1 = "visible-conflicts.v1"; +var OH_MEMORY_LIMITS_V1 = Object.freeze({ + explainCapabilityEntryBytes: 32 * 1024 * 1024, + explainCapabilities: 256, + explainCapabilityLifetimeMs: 15 * 60 * 1000, + explainCapabilityTotalBytes: 64 * 1024 * 1024, + factsPerRecordPerExtractor: 512, + maximumExtractorInvocations: 262144, + maximumExtractors: 32, + maximumNominationRoutes: 64, + maximumPrograms: 128, + maximumRecordsPerLane: 8192, + maximumSyntheticRecords: 16384, + rememberBytes: 8 * 1024 * 1024, + resultBytes: 32 * 1024 * 1024, + snapshotBytesPerLane: 32 * 1024 * 1024, + relationsPerExtractor: 64 +}); +var memoryFactPackPayload = Object.freeze({ + factPackId: "oh.memory.composite-facts", + factPackRevision: 1, + relations: Object.freeze(["memory.agreement", "memory.conflict", "memory.dependency", "memory.record"]), + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 +}); +var OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1 = Object.freeze({ + ...memoryFactPackPayload, + extractorSha256: canonicalSha256(memoryFactPackPayload) +}); +var builtInFactPolicy = Object.freeze({ + extractorSha256: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.extractorSha256, + factPackId: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackId, + kind: "built-in", + v: 1 +}); +function immutableClone(value) { + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => immutableClone(item))); + } + if (value !== null && typeof value === "object") { + if (!isPlainRecord(value)) + throw new TypeError("Memory output contains a non-JSON object."); + const cloned = {}; + for (const key of Object.keys(value)) { + Object.defineProperty(cloned, key, { + configurable: false, + enumerable: true, + value: immutableClone(value[key]), + writable: false + }); + } + return Object.freeze(cloned); + } + return value; +} +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} +function exactHead(left, right) { + return canonicalJson(left) === canonicalJson(right); +} +function authorityId(value) { + const parsed = safeCode(value, 128); + if (parsed === null) + throw new TypeError("Invalid memory authority ID."); + return parsed; +} +function bindingFor(store, expected, lane) { + const binding = parseOhStoreBindingV1(store.binding); + if (binding === null || binding.bindingSha256 !== parseSha256Hex(expected)) { + throw new OhIntegrityError(`The ${lane} store is not the host-bound authority.`); + } + if (binding.profile.profileKind !== lane) { + throw new OhProfileError(`The ${lane} memory lane has the wrong store profile.`); + } + return binding; +} +function laneIdentity(value) { + return Object.freeze({ + authorityId: value.authorityId, + bindingSha256: value.binding.bindingSha256, + datasetSha256: value.dataset.datasetSha256, + head: value.snapshot.head, + lane: value.lane, + snapshotSha256: value.projectionSnapshot.snapshotSha256, + v: 1 + }); +} +function datasetForSnapshot(binding, snapshot) { + const projectionSnapshot = createOhProjectionSnapshotV1({ + head: snapshot.head, + records: snapshot.records, + spaceId: binding.spaceId + }); + const dataset = createOhProjectionDatasetV1({ + extractorSha256: canonicalSha256({ extractor: "oh.memory.lane-structural", v: 1 }), + factPackId: "oh.memory.lane-structural", + factPackRevision: 1, + facts: createOhProjectionRecordFactsV1(snapshot.records), + snapshot: projectionSnapshot + }); + return { dataset, projectionSnapshot }; +} +async function readLane(authority, lane, expectedHead) { + const returnedHead = expectedHead ?? await authority.store.head(); + const head = parseOhHeadV1(immutableClone(returnedHead)); + if (head === null) + throw new OhIntegrityError(`The ${lane} store returned an invalid head.`); + const returnedSnapshot = await authority.store.snapshot({ + head: { operationSha256: head.operationSha256, sequence: head.sequence }, + maximumRecords: OH_MEMORY_LIMITS_V1.maximumRecordsPerLane + }); + if (!isPlainRecord(returnedSnapshot) || !hasExactKeys(returnedSnapshot, ["head", "records", "v"]) || returnedSnapshot.v !== 1 || !Array.isArray(returnedSnapshot.records)) { + throw new OhIntegrityError(`The ${lane} store returned an invalid snapshot envelope.`); + } + const detached = immutableClone(returnedSnapshot); + const detachedHead = parseOhHeadV1(detached.head); + if (detachedHead === null) + throw new OhIntegrityError(`The ${lane} store returned an invalid snapshot head.`); + const snapshot = immutableClone({ + head: detachedHead, + records: detached.records, + v: 1 + }); + if (!exactHead(snapshot.head, head)) { + throw new OhIntegrityError(`The ${lane} snapshot differs from its pinned head.`); + } + if (utf8ByteLength(canonicalJson(snapshot)) > OH_MEMORY_LIMITS_V1.snapshotBytesPerLane) { + throw new RangeError(`The ${lane} memory snapshot exceeds its canonical byte bound.`); + } + const projected = datasetForSnapshot(authority.binding, snapshot); + return Object.freeze({ + authorityId: authority.authorityId, + binding: authority.binding, + dataset: projected.dataset, + lane, + projectionSnapshot: projected.projectionSnapshot, + snapshot + }); +} +function syntheticKey(lane, recordSha256) { + return `memory-source:${lane}:${recordSha256}`; +} +function createSyntheticSources(lanes) { + const sources = new Map; + for (const lane of lanes) { + for (const physicalRecord of lane.snapshot.records) { + const key = syntheticKey(lane.lane, physicalRecord.recordSha256); + const record = createKnowledgeGraphRecordV1({ + dependencies: [], + key, + kind: "view", + v: 1, + value: { + authorityId: lane.authorityId, + bindingSha256: lane.binding.bindingSha256, + key: physicalRecord.key, + lane: lane.lane, + recordSha256: physicalRecord.recordSha256, + snapshotSha256: lane.projectionSnapshot.snapshotSha256, + v: 1 + } + }); + const physical = Object.freeze({ + authorityId: lane.authorityId, + bindingSha256: lane.binding.bindingSha256, + head: lane.snapshot.head, + key: physicalRecord.key, + lane: lane.lane, + recordSha256: physicalRecord.recordSha256, + snapshotSha256: lane.projectionSnapshot.snapshotSha256, + v: 1 + }); + if (sources.has(key)) + throw new OhIntegrityError("A memory lane contains a duplicate source digest."); + sources.set(key, Object.freeze({ physical, record })); + } + } + if (sources.size > OH_MEMORY_LIMITS_V1.maximumSyntheticRecords) { + throw new RangeError("The composite memory snapshot has too many records."); + } + const records = [...sources.values()].map(({ record }) => record).sort((left, right) => compareText(left.key, right.key)); + return { records, sources }; +} +function sourceFor(sources, lane, record) { + const source = sources.get(syntheticKey(lane, record.recordSha256)); + if (source === undefined) + throw new OhIntegrityError("A composite memory source is missing."); + return [{ key: source.record.key, recordSha256: source.record.recordSha256, v: 1 }]; +} +function createCompositeDataset(canonical, working, extractors) { + const synthetic = createSyntheticSources([canonical, working]); + const extractorInvocations = synthetic.records.length * extractors.length; + if (extractorInvocations > OH_MEMORY_LIMITS_V1.maximumExtractorInvocations) { + throw new RangeError("The composite memory extractor invocation count exceeds its explicit bound."); + } + const facts = []; + const factDigests = new Set; + const factPolicies = new Map; + const addFact = (fact, policy) => { + if (facts.length >= OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("The composite memory fact set exceeds its explicit bound."); + } + if (factDigests.has(fact.factSha256)) { + throw new OhIntegrityError("A memory fact extractor emitted the same exact fact twice."); + } + const priorPolicy = factPolicies.get(fact.relation); + if (priorPolicy !== undefined && canonicalJson(priorPolicy) !== canonicalJson(policy)) { + throw new OhIntegrityError("A memory relation has more than one fact policy."); + } + facts.push(fact); + factDigests.add(fact.factSha256); + factPolicies.set(fact.relation, policy); + }; + const byLane = new Map([ + ["canonical", new Map(canonical.snapshot.records.map((record) => [record.key, record]))], + ["working", new Map(working.snapshot.records.map((record) => [record.key, record]))] + ]); + for (const lane of [canonical, working]) { + for (const record of lane.snapshot.records) { + const extractorRecord = immutableClone(record); + const source = sourceFor(synthetic.sources, lane.lane, record); + addFact(createOhProjectionFactV1({ + relation: "memory.record", + sources: source, + tuple: [lane.lane, record.key, record.kind, record.recordSha256] + }), builtInFactPolicy); + for (const dependency of record.dependencies) { + addFact(createOhProjectionFactV1({ + relation: "memory.dependency", + sources: source, + tuple: [lane.lane, record.key, dependency] + }), builtInFactPolicy); + } + for (const extractor of extractors) { + const declared = extractor.extract(Object.freeze({ lane: lane.lane, record: extractorRecord })); + if (!Array.isArray(declared) || declared.length > OH_MEMORY_LIMITS_V1.factsPerRecordPerExtractor) { + throw new RangeError("A memory fact extractor exceeded its per-record bound."); + } + for (const fact of declared) { + if (!isPlainRecord(fact) || !hasExactKeys(fact, ["relation", "tuple", "v"]) || fact.v !== 1 || !Array.isArray(fact.tuple) || typeof fact.relation !== "string" || !extractor.relations.includes(fact.relation)) { + throw new TypeError("A memory fact extractor returned an invalid or reserved fact."); + } + addFact(createOhProjectionFactV1({ relation: fact.relation, sources: source, tuple: fact.tuple }), Object.freeze({ + extractorId: extractor.extractorId, + extractorSha256: extractor.extractorSha256, + kind: "domain", + v: 1 + })); + } + } + } + } + const conflicts = []; + const canonicalByKey = byLane.get("canonical"); + const workingByKey = byLane.get("working"); + for (const key of [...canonicalByKey.keys()].filter((candidate) => workingByKey.has(candidate)).sort()) { + const canonicalRecord = canonicalByKey.get(key); + const workingRecord = workingByKey.get(key); + const sources = [ + ...sourceFor(synthetic.sources, "canonical", canonicalRecord), + ...sourceFor(synthetic.sources, "working", workingRecord) + ]; + if (canonicalRecord.recordSha256 === workingRecord.recordSha256) { + addFact(createOhProjectionFactV1({ + relation: "memory.agreement", + sources, + tuple: [key, canonicalRecord.recordSha256] + }), builtInFactPolicy); + } else { + addFact(createOhProjectionFactV1({ + relation: "memory.conflict", + sources, + tuple: [key, canonicalRecord.recordSha256, workingRecord.recordSha256] + }), builtInFactPolicy); + conflicts.push(Object.freeze({ + canonicalRecordSha256: canonicalRecord.recordSha256, + key, + v: 1, + workingRecordSha256: workingRecord.recordSha256 + })); + } + } + const recordRefs = synthetic.records.map(knowledgeGraphRecordRefV1).sort((left, right) => compareText(left.key, right.key)); + const sourceIdentity = { + canonical: laneIdentity(canonical), + conflictPolicy: OH_MEMORY_CONFLICT_POLICY_V1, + recordRefs, + v: 1, + working: laneIdentity(working) + }; + const head = Object.freeze({ + generation: 1, + graphRevisionSha256: canonicalSha256({ kind: "oh.memory.composite-graph", sourceIdentity }), + operationSha256: canonicalSha256({ kind: "oh.memory.composite-operation", sourceIdentity }), + recordsSha256: canonicalSha256(recordRefs), + sequence: 1, + v: 1 + }); + const snapshot = createOhProjectionSnapshotV1({ + head, + records: synthetic.records, + spaceId: "oh.memory.composite" + }); + const dataset = createOhProjectionDatasetV1({ + extractorSha256: canonicalSha256({ + builtIn: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.extractorSha256, + extensions: extractors.map(({ extractorId, extractorSha256, relations }) => ({ + extractorId, + extractorSha256, + relations + })), + v: 1 + }), + factPackId: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackId, + factPackRevision: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackRevision, + facts, + snapshot + }); + return Object.freeze({ + conflicts: Object.freeze(conflicts), + dataset, + factPolicies, + snapshot, + sources: synthetic.sources + }); +} +function mapProof(proof, sources, factPolicies) { + if (proof.kind === "truncated") + return Object.freeze({ ...proof }); + if (proof.kind === "derived") { + return Object.freeze({ + ...proof, + premises: Object.freeze(proof.premises.map((premise) => mapProof(premise, sources, factPolicies))) + }); + } + const physical = proof.sources.map((source) => { + const mapped = sources.get(source.key); + if (mapped === undefined || mapped.record.recordSha256 !== source.recordSha256) { + throw new OhIntegrityError("A projection proof has no exact physical memory source."); + } + return mapped.physical; + }).sort((left, right) => compareText(canonicalJson(left), canonicalJson(right))); + const factPolicy = factPolicies.get(proof.relation); + if (factPolicy === undefined) + throw new OhIntegrityError("A projection proof has no memory fact policy."); + return Object.freeze({ + factPolicy, + kind: "fact", + relation: proof.relation, + sources: Object.freeze(physical), + tuple: proof.tuple, + v: 1 + }); +} +function collectLanes(proof, lanes) { + if (proof.kind === "truncated") + return true; + if (proof.kind === "fact") { + for (const source of proof.sources) + lanes.add(source.lane); + return false; + } + let unknown = proof.premisesTruncated; + for (const premise of proof.premises) + unknown = collectLanes(premise, lanes) || unknown; + return unknown; +} +function publicRow(row, proofs) { + const lanes = new Set; + let unknown = row.proofsTruncated; + for (const proof of proofs) + unknown = collectLanes(proof, lanes) || unknown; + const premiseLanes = [...lanes].sort(); + const premiseAuthority = unknown || premiseLanes.length === 0 ? "unknown" : premiseLanes.includes("working") ? "working" : "canonical"; + const payload = { + premiseAuthority, + premiseLanes, + proofsTruncated: row.proofsTruncated, + supportCount: row.supportCount, + v: 1, + values: row.values + }; + return Object.freeze({ ...payload, resultRowSha256: canonicalSha256(payload) }); +} +function resolvePrograms(programs) { + if (programs.length < 1 || programs.length > OH_MEMORY_LIMITS_V1.maximumPrograms) { + throw new RangeError("Memory requires a bounded nonempty named program registry."); + } + const resolved = new Map; + for (const program of programs) { + const programId = safeCode(program.programId, 128); + const purpose = safeCode(program.purpose, 256); + const query = parseOhProjectionQueryV1(program.query); + const rulePack = parseOhProjectionRulePackV1(program.rulePack); + if (programId === null || purpose === null || query === null || rulePack === null || resolved.has(programId)) { + throw new TypeError("Invalid or duplicate named memory program."); + } + resolved.set(programId, immutableClone({ ...program.evaluation === undefined ? {} : { evaluation: { ...program.evaluation } }, programId, purpose, query, rulePack })); + } + return resolved; +} +function resolveExtractors(extractors) { + if (extractors.length > OH_MEMORY_LIMITS_V1.maximumExtractors) { + throw new RangeError("The memory domain extractor registry is too large."); + } + const claimedRelations = new Set; + const resolved = extractors.map((extractor) => { + const extractorId = safeCode(extractor.extractorId, 128); + const extractorSha256 = parseSha256Hex(extractor.extractorSha256); + if (extractorId === null || extractorSha256 === null || typeof extractor.extract !== "function" || !Array.isArray(extractor.relations) || extractor.relations.length < 1 || extractor.relations.length > OH_MEMORY_LIMITS_V1.relationsPerExtractor) { + throw new TypeError("Invalid memory domain fact extractor."); + } + const relations = extractor.relations.map((relation) => safeCode(relation, 128)).sort(); + if (relations.some((relation) => relation === null || relation.startsWith("memory.") || relation.startsWith("oh.")) || new Set(relations).size !== relations.length) { + throw new TypeError("A memory domain fact extractor has invalid or reserved relations."); + } + for (const relation of relations) { + if (claimedRelations.has(relation)) { + throw new TypeError("Memory domain fact extractor relations must have one owner."); + } + claimedRelations.add(relation); + } + return Object.freeze({ + extract: extractor.extract, + extractorId, + extractorSha256, + relations: Object.freeze(relations) + }); + }).sort((left, right) => compareText(left.extractorId, right.extractorId)); + if (new Set(resolved.map(({ extractorId }) => extractorId)).size !== resolved.length) { + throw new TypeError("Duplicate memory domain fact extractor ID."); + } + return Object.freeze(resolved); +} +function resolveNominationRoutes(routes) { + if (routes.length > OH_MEMORY_LIMITS_V1.maximumNominationRoutes) { + throw new RangeError("The memory nomination route registry is too large."); + } + const resolved = new Map; + for (const route of routes) { + const nominationId = safeCode(route.nominationId, 128); + const destinationPurpose = safeCode(route.destinationPurpose, 256); + if (nominationId === null || destinationPurpose === null || resolved.has(nominationId)) { + throw new TypeError("Invalid or duplicate memory nomination route."); + } + resolved.set(nominationId, Object.freeze({ destinationPurpose, nominationId })); + } + return resolved; +} +function parseQueryRequest(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["programId", "v"]) || value.v !== 1) + throw new TypeError("Invalid named memory query."); + const programId = safeCode(value.programId, 128); + if (programId === null) + throw new TypeError("Invalid named memory query identity."); + return { programId }; +} +function parseExplainRequest(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["resultSha256", "row", "token", "v"]) || value.v !== 1 || typeof value.token !== "string" || value.token.length !== 43 || !Number.isSafeInteger(value.row) || value.row < 0) { + throw new TypeError("Invalid memory explanation request."); + } + const resultSha256 = parseSha256Hex(value.resultSha256); + if (resultSha256 === null) + throw new TypeError("Invalid memory explanation result identity."); + return { resultSha256, row: value.row, token: value.token }; +} +function parseNominationRequest(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["nominationId", "roots", "v"]) || value.v !== 1 || !Array.isArray(value.roots) || value.roots.length < 1 || value.roots.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new TypeError("Invalid memory nomination request."); + } + const nominationId = safeCode(value.nominationId, 128); + const roots = value.roots.map((root) => safeCode(root, 512)).sort(); + if (nominationId === null || roots.some((root) => root === null) || new Set(roots).size !== roots.length) + throw new TypeError("Invalid memory nomination identity."); + return { nominationId, roots }; +} +function isoInstant(date) { + const value = date.toISOString(); + if (parseCanonicalInstantV1(value) === null) + throw new TypeError("The memory clock returned an invalid instant."); + return value; +} +function clockMilliseconds(now) { + const milliseconds = now().getTime(); + if (!Number.isFinite(milliseconds)) + throw new TypeError("The memory clock returned an invalid date."); + return milliseconds; +} +function monotonicMilliseconds(now) { + const milliseconds = now(); + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new TypeError("The memory monotonic clock returned an invalid value."); + } + return milliseconds; +} +async function createOhMemoryAgentV1(options) { + const memoryActorId = safeCode(options.actorId, 128); + if (memoryActorId === null) + throw new TypeError("Invalid host-bound memory actor ID."); + const canonicalStore = options.canonical.store; + const workingStore = options.working.store; + const workingCodecs = options.working.codecs; + const canonicalAuthorityId = authorityId(options.canonical.authorityId); + const workingAuthorityId = authorityId(options.working.authorityId); + if (canonicalAuthorityId === workingAuthorityId) { + throw new OhProfileError("Working and canonical memory must be distinct physical authorities."); + } + const canonicalBinding = bindingFor(canonicalStore, options.canonical.expectedBindingSha256, "canonical"); + const workingBinding = bindingFor(workingStore, options.working.expectedBindingSha256, "working"); + const expectedCanonicalHead = parseOhHeadV1(options.canonical.expectedHead); + if (expectedCanonicalHead === null) + throw new TypeError("Invalid pinned canonical memory head."); + const programs = resolvePrograms(options.programs); + const extractors = resolveExtractors(options.extractors ?? []); + const nominationRoutes = resolveNominationRoutes(options.nominationRoutes ?? []); + const ingress = new OhSemanticBundleIngressV1(workingStore, workingCodecs); + const now = options.now ?? (() => new Date); + const monotonicNow = options.monotonicNow ?? (() => performance.now()); + const capabilityLifetime = options.explainCapabilityLifetimeMs ?? OH_MEMORY_LIMITS_V1.explainCapabilityLifetimeMs; + if (!Number.isSafeInteger(capabilityLifetime) || capabilityLifetime < 1000 || capabilityLifetime > 60 * 60 * 1000) { + throw new RangeError("Invalid memory explanation capability lifetime."); + } + const canonical = await readLane({ + authorityId: canonicalAuthorityId, + binding: canonicalBinding, + store: canonicalStore + }, "canonical", expectedCanonicalHead); + const explanations = new Map; + let explanationBytes = 0; + let lastMonotonicMs = -1; + let lastWallClockMs = Number.NEGATIVE_INFINITY; + const wallClock = () => { + const milliseconds = clockMilliseconds(now); + if (milliseconds < lastWallClockMs) + throw new OhProfileError("The memory wall clock regressed."); + lastWallClockMs = milliseconds; + return milliseconds; + }; + const monotonicClock = () => { + const milliseconds = monotonicMilliseconds(monotonicNow); + if (milliseconds < lastMonotonicMs) + throw new OhProfileError("The memory monotonic clock regressed."); + lastMonotonicMs = milliseconds; + return milliseconds; + }; + const deleteExplanation = (token) => { + const stored = explanations.get(token); + if (stored !== undefined && explanations.delete(token)) + explanationBytes -= stored.bytes; + }; + const remember = async (value) => { + if (utf8ByteLength(canonicalJson(value)) > OH_MEMORY_LIMITS_V1.rememberBytes) { + throw new RangeError("The memory semantic bundle exceeds its canonical byte bound."); + } + if (!isPlainRecord(value) || !hasExactKeys(value, ["expectedHead", "puts", "requestId", "tombstones", "v"]) || value.v !== 1) { + throw new TypeError("Invalid memory remember request."); + } + const requestId = safeCode(value.requestId, 128); + if (requestId === null) + throw new TypeError("Invalid memory remember request identity."); + const operationId = `memory_${canonicalSha256({ + actorId: memoryActorId, + bindingSha256: workingBinding.bindingSha256, + requestId, + v: 1 + }).slice(0, 48)}`; + const operation = await ingress.commit({ + actorId: memoryActorId, + expectedHead: value.expectedHead, + instant: isoInstant(new Date(wallClock())), + operationId, + puts: value.puts, + tombstones: value.tombstones, + v: 1 + }); + const head = { + generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, + recordsSha256: operation.recordsSha256, + sequence: operation.sequence, + v: 1 + }; + const payload = { + actorId: operation.actorId, + authorityId: workingAuthorityId, + bindingSha256: workingBinding.bindingSha256, + head, + instant: operation.instant, + lane: "working", + operationSha256: operation.operationSha256, + requestId, + status: "committed", + v: 1 + }; + return immutableClone({ ...payload, receiptSha256: canonicalSha256(payload) }); + }; + const query = async (value) => { + const request = parseQueryRequest(value); + const program = programs.get(request.programId); + if (program === undefined) + throw new TypeError("Unknown named memory program."); + const working = await readLane({ + authorityId: workingAuthorityId, + binding: workingBinding, + store: workingStore + }, "working"); + const composite = createCompositeDataset(canonical, working, extractors); + const projection = evaluateOhProjectionV1({ + dataset: composite.dataset, + ...program.evaluation === undefined ? {} : { options: program.evaluation }, + query: program.query, + rulePack: program.rulePack, + snapshot: composite.snapshot + }); + const identityPayload = { + canonical: laneIdentity(canonical), + compositeDatasetSha256: composite.dataset.datasetSha256, + conflictPolicy: OH_MEMORY_CONFLICT_POLICY_V1, + evaluationSha256: projection.identity.evaluationSha256, + programId: program.programId, + projectionSha256: projection.identity.projectionSha256, + purpose: program.purpose, + querySha256: program.query.querySha256, + rulePackSha256: program.rulePack.rulePackSha256, + v: 1, + working: laneIdentity(working) + }; + const identity = immutableClone({ + ...identityPayload, + memorySha256: canonicalSha256(identityPayload) + }); + const proofs = immutableClone(projection.rows.map((row) => row.proofs.map((proof) => mapProof(proof, composite.sources, composite.factPolicies)))); + const rows = immutableClone(projection.rows.map((row, index) => publicRow(row, proofs[index]))); + const resultPayload = immutableClone({ + authority: "derived", + conflicts: composite.conflicts, + identity, + projectionResultSha256: projection.resultSha256, + rows, + v: 1 + }); + const resultSha256 = canonicalSha256(resultPayload); + const issuedAt = wallClock(); + const issuedAtMonotonic = monotonicClock(); + const expiresAtMs = issuedAt + capabilityLifetime; + const expiresAtMonotonicMs = issuedAtMonotonic + capabilityLifetime; + const expiresAt = isoInstant(new Date(expiresAtMs)); + for (const [existingToken, stored] of explanations) { + if (issuedAtMonotonic >= stored.expiresAtMonotonicMs) + deleteExplanation(existingToken); + } + const storedPayload = immutableClone({ expiresAtMonotonicMs, identity, proofs, resultSha256, rows }); + const storedBytes = utf8ByteLength(canonicalJson(storedPayload)) + 128; + if (storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityEntryBytes || storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityTotalBytes) { + throw new RangeError("The memory explanation exceeds its retained capability bound."); + } + while (explanations.size >= OH_MEMORY_LIMITS_V1.explainCapabilities || explanationBytes + storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityTotalBytes) { + const oldest = explanations.keys().next().value; + if (oldest === undefined) + break; + deleteExplanation(oldest); + } + let token = randomBytes2(32).toString("base64url"); + while (explanations.has(token)) + token = randomBytes2(32).toString("base64url"); + explanations.set(token, immutableClone({ ...storedPayload, bytes: storedBytes })); + explanationBytes += storedBytes; + const result = immutableClone({ + ...resultPayload, + explainCapability: { expiresAt, token, v: 1 }, + resultSha256 + }); + if (utf8ByteLength(canonicalJson(result)) > OH_MEMORY_LIMITS_V1.resultBytes) { + deleteExplanation(token); + throw new RangeError("The composite memory result exceeds its canonical byte bound."); + } + return result; + }; + const explain = async (value) => { + const request = parseExplainRequest(value); + const stored = explanations.get(request.token); + const currentTime = monotonicClock(); + if (stored === undefined || stored.resultSha256 !== request.resultSha256 || currentTime >= stored.expiresAtMonotonicMs) { + deleteExplanation(request.token); + throw new OhProfileError("The memory explanation capability is absent, expired, or misbound."); + } + const row = stored.rows[request.row]; + const proofs = stored.proofs[request.row]; + if (row === undefined || proofs === undefined) + throw new RangeError("The explanation row is out of bounds."); + const payload = { + authority: "derived", + identity: stored.identity, + premiseAuthority: row.premiseAuthority, + premiseLanes: row.premiseLanes, + proofs, + proofsTruncated: row.proofsTruncated, + resultRowSha256: row.resultRowSha256, + resultSha256: stored.resultSha256, + supportCount: row.supportCount, + v: 1, + values: row.values + }; + return immutableClone({ ...payload, explanationSha256: canonicalSha256(payload) }); + }; + const nominate = async (value) => { + const request = parseNominationRequest(value); + const route = nominationRoutes.get(request.nominationId); + if (route === undefined) + throw new TypeError("Unknown named memory nomination route."); + const head = parseOhHeadV1(immutableClone(await workingStore.head())); + if (head === null) + throw new OhIntegrityError("The working nomination store returned an invalid head."); + const closure = await workingStore.exportDependencyClosure({ head: { + operationSha256: head.operationSha256, + sequence: head.sequence + }, roots: request.roots }); + const verified = verifyOhDependencyClosureAgainstV1(closure, { binding: workingBinding, head }); + if (!verified.ok) + throw new OhIntegrityError("The working nomination closure failed exact verification."); + if (canonicalJson(verified.closure.roots) !== canonicalJson(request.roots)) { + throw new OhIntegrityError("The working nomination closure substituted different roots."); + } + const source = Object.freeze({ + authorityId: workingAuthorityId, + bindingSha256: workingBinding.bindingSha256, + head, + lane: "working", + v: 1 + }); + const payload = { + closure: verified.closure, + destinationPurpose: route.destinationPurpose, + nominationId: route.nominationId, + source, + status: "prepared", + v: 1 + }; + return immutableClone({ ...payload, nominationSha256: canonicalSha256(payload) }); + }; + return Object.freeze({ explain, nominate, query, remember }); +} +export { + createOhMemoryAgentV1, + OH_MEMORY_LIMITS_V1, + OH_MEMORY_FORMAT_VERSION_V1, + OH_MEMORY_CONFLICT_POLICY_V1, + OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1 +}; diff --git a/dist/projection-public.d.ts b/dist/projection-public.d.ts index dc7b9b1..b05bf4d 100644 --- a/dist/projection-public.d.ts +++ b/dist/projection-public.d.ts @@ -13,10 +13,13 @@ export declare const OH_PROJECTION_LIMITS_V1: Readonly<{ queryMatches: 262144; queryResults: 65536; relations: 4096; + resultBytes: number; rounds: 1024; rules: 1024; sourcesPerFact: 64; + totalProofNodes: 65536; variables: 256; + workUnits: 16777216; }>; export declare const OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1: Readonly<{ extractorSha256: import("./canonical").Sha256Hex; @@ -45,7 +48,9 @@ export declare const parseOhProjectionDatasetV1: typeof Projection.parseOhProjec export declare const parseOhProjectionFactV1: typeof Projection.parseOhProjectionFactV1; export declare const parseOhProjectionIdentityV1: typeof Projection.parseOhProjectionIdentityV1; export declare const parseOhProjectionLiteralV1: typeof Projection.parseOhProjectionLiteralV1; +export declare const parseOhProjectionProofV1: typeof Projection.parseOhProjectionProofV1; export declare const parseOhProjectionQueryV1: typeof Projection.parseOhProjectionQueryV1; +export declare const parseOhProjectionResultV1: typeof Projection.parseOhProjectionResultV1; export declare const parseOhProjectionRulePackV1: typeof Projection.parseOhProjectionRulePackV1; export declare const parseOhProjectionRuleV1: typeof Projection.parseOhProjectionRuleV1; export declare const parseOhProjectionSnapshotV1: typeof Projection.parseOhProjectionSnapshotV1; diff --git a/dist/projection-public.d.ts.map b/dist/projection-public.d.ts.map index e3ea959..24309c2 100644 --- a/dist/projection-public.d.ts.map +++ b/dist/projection-public.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"projection-public.d.ts","sourceRoot":"","sources":["../src/projection-public.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,UAAU,MAAM,cAAc,CAAC;AAE3C,eAAO,MAAM,+BAA+B,GAA6C,CAAC;AAC1F,eAAO,MAAM,gCAAgC,wBAA8C,CAAC;AAC5F,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;EAAqC,CAAC;AAC1E,eAAO,MAAM,sCAAsC;;;;;;;EAAoD,CAAC;AACxG,eAAO,MAAM,0BAA0B,qCAAwC,CAAC;AAChF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,yBAAyB,6CAAuC,CAAC;AAC9E,eAAO,MAAM,+BAA+B,mDAA6C,CAAC;AAC1F,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,6BAA6B,iDAA2C,CAAC;AACtF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAE1E,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,+BAA+B,EAC/B,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,gCAAgC,EAChC,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,+BAA+B,EAC/B,uBAAuB,EACvB,oBAAoB,EACpB,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,cAAc,CAAC"} \ No newline at end of file +{"version":3,"file":"projection-public.d.ts","sourceRoot":"","sources":["../src/projection-public.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,UAAU,MAAM,cAAc,CAAC;AAE3C,eAAO,MAAM,+BAA+B,GAA6C,CAAC;AAC1F,eAAO,MAAM,gCAAgC,wBAA8C,CAAC;AAC5F,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;EAAqC,CAAC;AAC1E,eAAO,MAAM,sCAAsC;;;;;;;EAAoD,CAAC;AACxG,eAAO,MAAM,0BAA0B,qCAAwC,CAAC;AAChF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,yBAAyB,6CAAuC,CAAC;AAC9E,eAAO,MAAM,+BAA+B,mDAA6C,CAAC;AAC1F,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,4BAA4B,gDAA0C,CAAC;AACpF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,6BAA6B,iDAA2C,CAAC;AACtF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,sBAAsB,0CAAoC,CAAC;AACxE,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,0BAA0B,8CAAwC,CAAC;AAChF,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,wBAAwB,4CAAsC,CAAC;AAC5E,eAAO,MAAM,yBAAyB,6CAAuC,CAAC;AAC9E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAC1E,eAAO,MAAM,2BAA2B,+CAAyC,CAAC;AAClF,eAAO,MAAM,uBAAuB,2CAAqC,CAAC;AAE1E,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,+BAA+B,EAC/B,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,gCAAgC,EAChC,0BAA0B,EAC1B,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,EACnB,+BAA+B,EAC/B,uBAAuB,EACvB,oBAAoB,EACpB,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/projection-public.js b/dist/projection-public.js index 89dbd64..e97265e 100644 --- a/dist/projection-public.js +++ b/dist/projection-public.js @@ -1,6 +1,5 @@ -// @bun // src/canonical.ts -import { createHash, randomBytes } from "crypto"; +import { createHash, randomBytes } from "node:crypto"; class OhValidationError extends Error { code; @@ -138,30 +137,9 @@ function parseCanonicalInstantV1(value) { function canonicalNow() { return new Date().toISOString(); } -function opaqueId(prefix) { - if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) { - throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code"); - } - return `${prefix}${randomBytes(12).toString("hex")}`; -} function safeCode(value, maximumLength = 128) { return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; } -function boundedText(value, maximumBytes = 64 * 1024) { - if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value || utf8ByteLength(value) > maximumBytes) - return null; - try { - assertUnicodeScalarString(value, "$text"); - } catch { - return null; - } - for (const character of value) { - const code = character.codePointAt(0) ?? 0; - if (code <= 8 || code >= 11 && code <= 12 || code >= 14 && code <= 31 || code >= 127 && code <= 159) - return null; - } - return value; -} function orderedUnique(values, key) { return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); } @@ -282,128 +260,6 @@ function graphRevisionSha256V1(input) { } return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); } -function createKnowledgeGraphRevisionV1(input) { - if (input.parent !== null && parseKnowledgeGraphRevisionV1(input.parent) === null) { - throw new TypeError("Invalid parent graph revision."); - } - const operationId = safeCode(input.operationId); - const changes = canonicalKnowledgeGraphChangesV1(input.changes); - if (operationId === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) - throw new TypeError("Invalid graph revision."); - const byKey = new Map((input.parent?.recordRefs ?? []).map((ref) => [ref.key, ref])); - for (const change of changes) { - if (change.kind === "put") { - for (const dependency of change.record.dependencies) { - if (!byKey.has(dependency) && !changes.some((candidate) => candidate.kind === "put" && candidate.record.key === dependency)) { - throw new TypeError(`Missing graph dependency: ${dependency}`); - } - } - byKey.set(change.record.key, knowledgeGraphRecordRefV1(change.record)); - } else { - const prior = byKey.get(change.key); - if (prior === undefined || prior.sha256 !== change.priorSha256) - throw new TypeError("Tombstone prior digest does not match."); - byKey.delete(change.key); - } - } - if (byKey.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { - throw new RangeError("Graph revision exceeds its record snapshot limit."); - } - for (const ref of byKey.values()) { - if (ref.dependencies.some((dependency) => !byKey.has(dependency))) { - throw new TypeError(`Missing graph dependency after revision: ${ref.key}`); - } - } - const recordRefs = [...byKey.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); - const recordsSha256 = canonicalSha256(recordRefs); - const payload = { - changes, - operationId, - parentGraphRevisionSha256: input.parent?.graphRevisionSha256 ?? null, - recordRefs, - recordsSha256, - revision: (input.parent?.revision ?? 0) + 1, - v: 1 - }; - return { ...payload, graphRevisionSha256: graphRevisionSha256V1(payload) }; -} -function parseKnowledgeGraphRevisionV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "changes", - "graphRevisionSha256", - "operationId", - "parentGraphRevisionSha256", - "recordRefs", - "recordsSha256", - "revision", - "v" - ]) || value.v !== 1 || !Array.isArray(value.changes) || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) - return null; - const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); - const recordsSha256 = parseSha256Hex(value.recordsSha256); - const parentGraphRevisionSha256 = value.parentGraphRevisionSha256 === null ? null : parseSha256Hex(value.parentGraphRevisionSha256); - const operationId = safeCode(value.operationId); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - let changes; - try { - changes = canonicalKnowledgeGraphChangesV1(value.changes); - } catch { - return null; - } - const refs = []; - for (const item of value.recordRefs) { - if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "sha256", "v"]) || item.v !== 1 || !Array.isArray(item.dependencies)) - return null; - const dependencies = item.dependencies.map(recordKey); - const key = recordKey(item.key); - const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === item.kind); - const sha256 = parseSha256Hex(item.sha256); - if (key === null || kind === undefined || sha256 === null || dependencies.some((dependency) => dependency === null) || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || !orderedUnique(dependencies, String) || dependencies.includes(key)) - return null; - refs.push({ dependencies, key, kind, sha256, v: 1 }); - } - if (graphRevisionSha256 === null || recordsSha256 === null || operationId === null || revision === null || value.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || !orderedUnique(refs, (ref) => ref.key) || canonicalSha256(refs) !== recordsSha256) - return null; - const keys = new Set(refs.map((ref) => ref.key)); - if (refs.some((ref) => ref.dependencies.some((dependency) => !keys.has(dependency)))) - return null; - const payload = { - changes, - operationId, - parentGraphRevisionSha256, - recordRefs: refs, - recordsSha256, - revision, - v: 1 - }; - try { - return graphRevisionSha256V1(payload) === graphRevisionSha256 ? { ...payload, graphRevisionSha256 } : null; - } catch { - return null; - } -} -function reduceKnowledgeGraphRevisionsV1(revisions) { - if (revisions.length === 0 || revisions.length > 65536) - return null; - const ordered = [...revisions].sort((left, right) => left.revision - right.revision); - let parent = null; - const operationIds = new Set; - for (const candidate of ordered) { - const current = parseKnowledgeGraphRevisionV1(candidate); - if (current === null || current.revision !== (parent?.revision ?? 0) + 1 || current.parentGraphRevisionSha256 !== (parent?.graphRevisionSha256 ?? null) || operationIds.has(current.operationId)) - return null; - try { - const rebuilt = createKnowledgeGraphRevisionV1({ changes: current.changes, operationId: current.operationId, parent }); - if (rebuilt.graphRevisionSha256 !== current.graphRevisionSha256) - return null; - } catch { - return null; - } - operationIds.add(current.operationId); - parent = current; - } - return parent; -} // src/ontology.ts var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; @@ -415,591 +271,9 @@ var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ statementBytes: 256 * 1024, textBytes: 64 * 1024 }); -var OH_KNOWLEDGE_KERNEL_CONCEPTS_V1 = [ - { code: "entity", description: "A stable identity anchor for something that can be referred to.", label: "Entity" }, - { code: "statement", description: "An immutable proposition with a subject, predicate, object, and qualifiers.", label: "Statement" }, - { code: "assertion", description: "An attributable stance toward a statement.", label: "Assertion" }, - { code: "evidence", description: "A typed account of how an observation bears on an assertion.", label: "Evidence" }, - { code: "context", description: "The scenario and dimensions in which knowledge applies.", label: "Context" }, - { code: "inquiry", description: "A question and its durable investigation trail.", label: "Inquiry" }, - { code: "projection", description: "A reproducible view derived from exact knowledge.", label: "Projection" } -]; -function success(value) { - return { ok: true, value }; -} -function failure(field, code = "invalid-input") { - return { error: { code, field }, ok: false }; -} -function parseOpaqueId(value, prefix) { - return typeof value === "string" && new RegExp(`^${prefix}[a-z0-9]{24}$`, "u").test(value) ? value : null; -} -function parseKnowledgeEntityId(value) { - return parseOpaqueId(value, "kent_"); -} -function parseKnowledgeAssertionId(value) { - return parseOpaqueId(value, "kast_"); -} -function parseKnowledgeEvidenceId(value) { - return parseOpaqueId(value, "kevd_"); -} -function parseKnowledgeInquiryId(value) { - return parseOpaqueId(value, "kinq_"); -} -var OH_KNOWLEDGE_ENTITY_STATES_V1 = ["active", "quarantined", "redirected", "tombstoned"]; -function parseKnowledgeEntityV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "entityId", - "identityOperationId", - "identityRevision", - "redirectEntityId", - "state", - "v" - ]) || value.v !== 1) - return failure("entity"); - const entityId = parseKnowledgeEntityId(value.entityId); - const identityOperationId = safeCode(value.identityOperationId); - const identityRevision = Number.isSafeInteger(value.identityRevision) && value.identityRevision > 0 ? value.identityRevision : null; - const redirectEntityId = value.redirectEntityId === null ? null : parseKnowledgeEntityId(value.redirectEntityId); - const state = OH_KNOWLEDGE_ENTITY_STATES_V1.find((candidate) => candidate === value.state); - return entityId !== null && identityOperationId !== null && identityRevision !== null && (value.redirectEntityId === null || redirectEntityId !== null) && state !== undefined && state === "redirected" === (redirectEntityId !== null) && redirectEntityId !== entityId ? success({ entityId, identityOperationId, identityRevision, redirectEntityId, state, v: 1 }) : failure("entity"); -} -function parseKnowledgeSchemaRefV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["code", "namespace", "revision", "schemaSha256", "v"]) || value.v !== 1) - return failure("schemaRef"); - const code = safeCode(value.code); - const namespace = safeCode(value.namespace); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - const schemaSha256 = parseSha256Hex(value.schemaSha256); - return code !== null && namespace !== null && revision !== null && schemaSha256 !== null ? success({ code, namespace, revision, schemaSha256, v: 1 }) : failure("schemaRef"); -} -var INTEGER = /^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$/u; -var DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/u; -function parseKnowledgeValueInternal(value, depth) { - if (!isPlainRecord(value) || value.v !== 1 || depth > 8) - return null; - switch (value.kind) { - case "entity": { - if (!hasExactKeys(value, ["entityId", "kind", "v"])) - return null; - const entityId = parseKnowledgeEntityId(value.entityId); - return entityId === null ? null : { entityId, kind: "entity", v: 1 }; - } - case "text": { - if (!hasExactKeys(value, ["kind", "language", "text", "v"])) - return null; - const language = typeof value.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(value.language) ? value.language : null; - const text = boundedText(value.text); - return language !== null && text !== null ? { kind: "text", language, text, v: 1 } : null; - } - case "string": { - const parsed = boundedText(value.value); - return hasExactKeys(value, ["kind", "v", "value"]) && parsed !== null ? { kind: "string", v: 1, value: parsed } : null; - } - case "boolean": - return hasExactKeys(value, ["kind", "v", "value"]) && typeof value.value === "boolean" ? { kind: "boolean", v: 1, value: value.value } : null; - case "integer": - case "decimal": { - const valid = typeof value.value === "string" && value.value.length <= 1024 && (value.kind === "integer" ? INTEGER.test(value.value) : DECIMAL.test(value.value) && value.value !== "-0"); - return hasExactKeys(value, ["kind", "v", "value"]) && valid ? { kind: value.kind, v: 1, value: value.value } : null; - } - case "uri": { - if (!hasExactKeys(value, ["kind", "uri", "v"]) || typeof value.uri !== "string" || value.uri.length > 4096) - return null; - try { - const url = new URL(value.uri); - return url.href === value.uri && url.username === "" && url.password === "" && !["data:", "file:", "javascript:"].includes(url.protocol) ? { kind: "uri", uri: value.uri, v: 1 } : null; - } catch { - return null; - } - } - case "list": - case "set": { - if (!hasExactKeys(value, ["kind", "v", "values"]) || !Array.isArray(value.values) || value.values.length > OH_KNOWLEDGE_LIMITS_V1.listValues) - return null; - const values = []; - for (const item of value.values) { - const parsed = parseKnowledgeValueInternal(item, depth + 1); - if (parsed === null) - return null; - values.push(parsed); - } - if (value.kind === "set" && !orderedUnique(values, canonicalJson)) - return null; - return { kind: value.kind, v: 1, values }; - } - case "extension": { - if (!hasExactKeys(value, ["canonicalizerSha256", "canonicalValue", "kind", "mediaType", "schema", "v", "valueSha256"])) - return null; - const canonicalizerSha256 = parseSha256Hex(value.canonicalizerSha256); - const canonicalValue = boundedText(value.canonicalValue, 64 * 1024); - const mediaType = typeof value.mediaType === "string" && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/u.test(value.mediaType) ? value.mediaType : null; - const schema = parseKnowledgeSchemaRefV1(value.schema); - const valueSha256 = parseSha256Hex(value.valueSha256); - return canonicalizerSha256 !== null && canonicalValue !== null && mediaType !== null && schema.ok && valueSha256 !== null ? { canonicalizerSha256, canonicalValue, kind: "extension", mediaType, schema: schema.value, v: 1, valueSha256 } : null; - } - default: - return null; - } -} -function parseKnowledgeValueV1(value) { - const parsed = parseKnowledgeValueInternal(value, 0); - return parsed === null ? failure("value") : success(parsed); -} -function verifyKnowledgeValueV1(value) { - const parsed = parseKnowledgeValueV1(value); - if (!parsed.ok) - return parsed; - if (parsed.value.kind === "extension" && sha256Hex(parsed.value.canonicalValue) !== parsed.value.valueSha256) { - return failure("valueSha256", "digest-mismatch"); - } - if (parsed.value.kind === "list" || parsed.value.kind === "set") { - for (const child of parsed.value.values) { - const verified = verifyKnowledgeValueV1(child); - if (!verified.ok) - return verified; - } - } - return success(parsed.value); -} -function parseDimension(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["predicate", "v", "value"]) || value.v !== 1) - return null; - const predicate = parseKnowledgeSchemaRefV1(value.predicate); - const parsedValue = parseKnowledgeValueV1(value.value); - return predicate.ok && parsedValue.ok ? { predicate: predicate.value, v: 1, value: parsedValue.value } : null; -} -function createKnowledgeContextV1(input) { - if (!isPlainRecord(input) || input.v !== 1 || !Array.isArray(input.dimensions) || input.dimensions.length > OH_KNOWLEDGE_LIMITS_V1.dimensions || !["actual", "counterfactual", "hypothetical", "planned"].includes(input.scenario)) - return failure("context"); - const dimensions = []; - for (const item of input.dimensions) { - const parsed = parseDimension(item); - if (parsed === null) - return failure("dimensions"); - const verified = verifyKnowledgeValueV1(parsed.value); - if (!verified.ok) - return verified; - dimensions.push(parsed); - } - let canonicalDimensions; - try { - canonicalDimensions = sortUnique(dimensions, canonicalJson); - } catch { - return failure("dimensions", "noncanonical-input"); - } - const payload = { dimensions: canonicalDimensions, scenario: input.scenario, v: 1 }; - return success({ ...payload, contextSha256: canonicalSha256(payload) }); -} -function parseKnowledgeContextV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["contextSha256", "dimensions", "scenario", "v"])) - return failure("context"); - const digest = parseSha256Hex(value.contextSha256); - if (digest === null) - return failure("contextSha256"); - const created = createKnowledgeContextV1({ dimensions: value.dimensions, scenario: value.scenario, v: value.v }); - return created.ok && created.value.contextSha256 === digest && canonicalJson(created.value.dimensions) === canonicalJson(value.dimensions) ? success({ ...created.value, contextSha256: digest }) : failure("contextSha256", "digest-mismatch"); -} -function createKnowledgeStatementV1(input) { - const object = parseKnowledgeValueV1(input.object); - const predicate = parseKnowledgeSchemaRefV1(input.predicate); - const subject = parseKnowledgeEntityId(input.subject); - if (input.v !== 1 || !object.ok || !predicate.ok || subject === null || !Array.isArray(input.qualifiers) || input.qualifiers.length > OH_KNOWLEDGE_LIMITS_V1.qualifiers) - return failure("statement"); - const verifiedObject = verifyKnowledgeValueV1(object.value); - if (!verifiedObject.ok) - return verifiedObject; - const qualifiers = []; - for (const item of input.qualifiers) { - const parsed = parseDimension(item); - if (parsed === null) - return failure("qualifiers"); - const verified = verifyKnowledgeValueV1(parsed.value); - if (!verified.ok) - return verified; - qualifiers.push(parsed); - } - let canonicalQualifiers; - try { - canonicalQualifiers = sortUnique(qualifiers, canonicalJson); - } catch { - return failure("qualifiers", "noncanonical-input"); - } - const payload = { object: object.value, predicate: predicate.value, qualifiers: canonicalQualifiers, subject, v: 1 }; - if (Buffer.byteLength(canonicalJson(payload), "utf8") > OH_KNOWLEDGE_LIMITS_V1.statementBytes) - return failure("statement", "limit-exceeded"); - return success({ ...payload, statementSha256: canonicalSha256(payload) }); -} -function parseKnowledgeStatementV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["object", "predicate", "qualifiers", "statementSha256", "subject", "v"])) - return failure("statement"); - const digest = parseSha256Hex(value.statementSha256); - const created = createKnowledgeStatementV1(value); - return digest !== null && created.ok && created.value.statementSha256 === digest && canonicalJson(created.value.qualifiers) === canonicalJson(value.qualifiers) ? success({ ...created.value, statementSha256: digest }) : failure("statementSha256", "digest-mismatch"); -} -function parseKnowledgeAgentRefV1(value) { - if (!isPlainRecord(value) || value.v !== 1) - return null; - if (value.kind === "entity" && hasExactKeys(value, ["entityId", "kind", "v"])) { - const entityId = parseKnowledgeEntityId(value.entityId); - return entityId === null ? null : { entityId, kind: "entity", v: 1 }; - } - if (value.kind === "model" && hasExactKeys(value, ["kind", "model", "receiptSha256", "v"])) { - const model = parseKnowledgeSchemaRefV1(value.model); - const receiptSha256 = parseSha256Hex(value.receiptSha256); - return model.ok && receiptSha256 !== null ? { kind: "model", model: model.value, receiptSha256, v: 1 } : null; - } - if (value.kind === "system" && hasExactKeys(value, ["authority", "kind", "receiptSha256", "v"])) { - const authority = parseKnowledgeSchemaRefV1(value.authority); - const receiptSha256 = parseSha256Hex(value.receiptSha256); - return authority.ok && receiptSha256 !== null ? { authority: authority.value, kind: "system", receiptSha256, v: 1 } : null; - } - return null; -} -function parseDigestArray(value, maximum = 2048) { - if (!Array.isArray(value) || value.length > maximum) - return null; - const digests = value.map(parseSha256Hex); - return digests.every((digest) => digest !== null) && orderedUnique(digests, String) ? digests : null; -} -var OH_KNOWLEDGE_ACTIVITY_KINDS_V1 = [ - "extraction", - "human-entry", - "human-review", - "import", - "model-proposal", - "normalization", - "publication", - "resolution", - "transformation" -]; -function parseActivityInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "actor", - "inputSha256s", - "kind", - "occurredAt", - "outputSha256s", - "policySha256", - "tool", - "v" - ]) || value.v !== 1) - return null; - const actor = parseKnowledgeAgentRefV1(value.actor); - const inputSha256s = parseDigestArray(value.inputSha256s); - const kind = OH_KNOWLEDGE_ACTIVITY_KINDS_V1.find((candidate) => candidate === value.kind); - const occurredAt = parseCanonicalInstantV1(value.occurredAt); - const outputSha256s = parseDigestArray(value.outputSha256s); - const policySha256 = parseSha256Hex(value.policySha256); - const tool = value.tool === null ? null : parseKnowledgeSchemaRefV1(value.tool); - const parsedTool = tool === null ? null : tool.ok ? tool.value : null; - return actor !== null && inputSha256s !== null && kind !== undefined && occurredAt !== null && outputSha256s !== null && policySha256 !== null && (value.tool === null || parsedTool !== null) ? { actor, inputSha256s, kind, occurredAt, outputSha256s, policySha256, tool: parsedTool, v: 1 } : null; -} -function createKnowledgeActivityV1(input) { - const parsed = parseActivityInput(input); - return parsed === null ? failure("activity") : success({ ...parsed, activitySha256: canonicalSha256(parsed) }); -} -function parseKnowledgeActivityV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "activitySha256")) - return failure("activity"); - const activitySha256 = parseSha256Hex(value.activitySha256); - const { activitySha256: _digest, ...input } = value; - const parsed = parseActivityInput(input); - return activitySha256 !== null && parsed !== null && canonicalSha256(parsed) === activitySha256 ? success({ ...parsed, activitySha256 }) : failure("activitySha256", "digest-mismatch"); -} -var OH_KNOWLEDGE_ASSERTION_STANCES_V1 = ["questions", "refutes", "reports", "supports", "undetermined"]; -var OH_KNOWLEDGE_ASSERTION_STATES_V1 = [ - "accepted-for-purpose", - "disputed", - "proposed", - "reviewed", - "superseded", - "withdrawn" -]; -function parseStringCodes(value, maximum) { - if (!Array.isArray(value) || value.length > maximum) - return null; - const codes = value.map((item) => safeCode(item)); - return codes.every((code) => code !== null) && orderedUnique(codes, String) ? codes : null; -} -function parseAssertionInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "acceptedPurposes", - "assertionId", - "assertor", - "confidence", - "contextSha256", - "provenanceActivitySha256", - "reviewActivitySha256", - "stance", - "state", - "statementSha256", - "v" - ]) || value.v !== 1) - return null; - const acceptedPurposes = parseStringCodes(value.acceptedPurposes, 32); - const assertionId = parseKnowledgeAssertionId(value.assertionId); - const assertor = parseKnowledgeAgentRefV1(value.assertor); - const confidence = value.confidence === null ? null : parseKnowledgeSchemaRefV1(value.confidence); - const parsedConfidence = confidence === null ? null : confidence.ok ? confidence.value : null; - const contextSha256 = value.contextSha256 === null ? null : parseSha256Hex(value.contextSha256); - const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); - const reviewActivitySha256 = value.reviewActivitySha256 === null ? null : parseSha256Hex(value.reviewActivitySha256); - const stance = OH_KNOWLEDGE_ASSERTION_STANCES_V1.find((candidate) => candidate === value.stance); - const state = OH_KNOWLEDGE_ASSERTION_STATES_V1.find((candidate) => candidate === value.state); - const statementSha256 = parseSha256Hex(value.statementSha256); - if (acceptedPurposes === null || assertionId === null || assertor === null || value.confidence !== null && parsedConfidence === null || value.contextSha256 !== null && contextSha256 === null || provenanceActivitySha256 === null || value.reviewActivitySha256 !== null && reviewActivitySha256 === null || stance === undefined || state === undefined || statementSha256 === null) - return null; - if (assertor.kind === "model" && (state !== "proposed" || acceptedPurposes.length !== 0 || reviewActivitySha256 !== null)) - return null; - if (state === "accepted-for-purpose" !== acceptedPurposes.length > 0 || state !== "proposed" && reviewActivitySha256 === null) - return null; - return { - acceptedPurposes, - assertionId, - assertor, - confidence: parsedConfidence, - contextSha256, - provenanceActivitySha256, - reviewActivitySha256, - stance, - state, - statementSha256, - v: 1 - }; -} -function createKnowledgeAssertionV1(input) { - const parsed = parseAssertionInput(input); - return parsed === null ? failure("assertion") : success({ ...parsed, assertionSha256: canonicalSha256(parsed) }); -} -function parseKnowledgeAssertionV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "assertionSha256")) - return failure("assertion"); - const assertionSha256 = parseSha256Hex(value.assertionSha256); - const { assertionSha256: _digest, ...input } = value; - const parsed = parseAssertionInput(input); - return assertionSha256 !== null && parsed !== null && canonicalSha256(parsed) === assertionSha256 ? success({ ...parsed, assertionSha256 }) : failure("assertionSha256", "digest-mismatch"); -} -var OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1 = [ - "background", - "contradicts", - "corroborates", - "direct-observation", - "method", - "quotation", - "registry-record", - "supports" -]; -function parseEvidenceInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "assertionSha256", - "bearing", - "disclosure", - "evidenceId", - "observationSha256", - "provenanceActivitySha256", - "selector", - "sourceEntityId", - "v" - ]) || value.v !== 1) - return null; - const assertionSha256 = parseSha256Hex(value.assertionSha256); - const bearing = OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1.find((candidate) => candidate === value.bearing); - const evidenceId = parseKnowledgeEvidenceId(value.evidenceId); - const observationSha256 = value.observationSha256 === null ? null : parseSha256Hex(value.observationSha256); - const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); - const selector = value.selector === null ? null : boundedText(value.selector, 8192); - const sourceEntityId = value.sourceEntityId === null ? null : parseKnowledgeEntityId(value.sourceEntityId); - return assertionSha256 !== null && bearing !== undefined && (value.disclosure === "private" || value.disclosure === "public" || value.disclosure === "shared") && evidenceId !== null && (value.observationSha256 === null || observationSha256 !== null) && provenanceActivitySha256 !== null && (value.selector === null || selector !== null) && (value.sourceEntityId === null || sourceEntityId !== null) && (observationSha256 !== null || sourceEntityId !== null) ? { - assertionSha256, - bearing, - disclosure: value.disclosure, - evidenceId, - observationSha256, - provenanceActivitySha256, - selector, - sourceEntityId, - v: 1 - } : null; -} -function createKnowledgeEvidenceLinkV1(input) { - const parsed = parseEvidenceInput(input); - return parsed === null ? failure("evidence") : success({ ...parsed, evidenceSha256: canonicalSha256(parsed) }); -} -function parseKnowledgeEvidenceLinkV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "evidenceSha256")) - return failure("evidence"); - const evidenceSha256 = parseSha256Hex(value.evidenceSha256); - const { evidenceSha256: _digest, ...input } = value; - const parsed = parseEvidenceInput(input); - return evidenceSha256 !== null && parsed !== null && canonicalSha256(parsed) === evidenceSha256 ? success({ ...parsed, evidenceSha256 }) : failure("evidenceSha256", "digest-mismatch"); -} -function createKnowledgeInquiryV1(input) { - const answerForm = safeCode(input.answerForm); - const authorEntityId = parseKnowledgeEntityId(input.authorEntityId); - const contextSha256 = input.contextSha256 === null ? null : parseSha256Hex(input.contextSha256); - const createdAt = parseCanonicalInstantV1(input.createdAt); - const inquiryId = parseKnowledgeInquiryId(input.inquiryId); - const language = typeof input.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(input.language) ? input.language : null; - const parents = Array.isArray(input.parentInquiryIds) ? input.parentInquiryIds.map(parseKnowledgeInquiryId) : null; - const question = boundedText(input.question, 16384); - if (input.v !== 1 || answerForm === null || authorEntityId === null || input.contextSha256 !== null && contextSha256 === null || createdAt === null || inquiryId === null || language === null || parents === null || parents.some((item) => item === null) || !orderedUnique(parents, String) || !["private", "public", "shared"].includes(input.privacy) || question === null || !["abandoned", "open", "paused", "resolved"].includes(input.status)) - return failure("inquiry"); - const payload = { - answerForm, - authorEntityId, - contextSha256, - createdAt, - inquiryId, - language, - parentInquiryIds: parents, - privacy: input.privacy, - question, - status: input.status, - v: 1 - }; - return success({ ...payload, inquirySha256: canonicalSha256(payload) }); -} -function parseKnowledgeInquiryV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "inquirySha256")) - return failure("inquiry"); - const digest = parseSha256Hex(value.inquirySha256); - const { inquirySha256: _digest, ...input } = value; - const created = createKnowledgeInquiryV1(input); - return digest !== null && created.ok && created.value.inquirySha256 === digest ? success({ ...created.value, inquirySha256: digest }) : failure("inquirySha256", "digest-mismatch"); -} // src/schema.ts var OH_SCHEMA_FORMAT_VERSION_V1 = 1; -var OH_SCHEMA_KINDS_V1 = ["concept", "mapping", "predicate", "shape", "unit", "vocabulary"]; -function parseLocalizedTexts(value) { - if (!Array.isArray(value) || value.length === 0 || value.length > 128) - return null; - const output = []; - for (const item of value) { - if (!isPlainRecord(item) || !hasExactKeys(item, ["language", "text", "v"]) || item.v !== 1) - return null; - const language = typeof item.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(item.language) ? item.language : null; - const text = boundedText(item.text, 16384); - if (language === null || text === null) - return null; - output.push({ language, text, v: 1 }); - } - return orderedUnique(output, canonicalJson) ? output : null; -} -function parseSchemaInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "body", - "code", - "compatibility", - "description", - "kind", - "labels", - "namespace", - "previousSchemaSha256", - "revision", - "v" - ]) || value.v !== 1 || !isPlainRecord(value.body)) - return null; - try { - canonicalJson(value.body); - } catch { - return null; - } - const code = safeCode(value.code); - const namespace = safeCode(value.namespace); - const kind = OH_SCHEMA_KINDS_V1.find((candidate) => candidate === value.kind); - const labels = parseLocalizedTexts(value.labels); - const description = parseLocalizedTexts(value.description); - const previousSchemaSha256 = value.previousSchemaSha256 === null ? null : parseSha256Hex(value.previousSchemaSha256); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - const compatibility = value.compatibility === "additive" || value.compatibility === "breaking" ? value.compatibility : null; - return code !== null && namespace !== null && kind !== undefined && labels !== null && description !== null && (value.previousSchemaSha256 === null || previousSchemaSha256 !== null) && revision !== null && compatibility !== null && revision === 1 === (previousSchemaSha256 === null) && (revision !== 1 || compatibility === "additive") ? { - body: value.body, - code, - compatibility, - description, - kind, - labels, - namespace, - previousSchemaSha256, - revision, - v: 1 - } : null; -} -function createKnowledgeSchemaRevisionV1(input) { - const parsed = parseSchemaInput(input); - if (parsed === null) - throw new TypeError("Invalid schema revision input."); - return { ...parsed, schemaSha256: canonicalSha256(parsed) }; -} -function parseKnowledgeSchemaRevisionV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "schemaSha256")) - return null; - const schemaSha256 = parseSha256Hex(value.schemaSha256); - const { schemaSha256: _digest, ...input } = value; - const parsed = parseSchemaInput(input); - return schemaSha256 !== null && parsed !== null && canonicalSha256(parsed) === schemaSha256 ? { ...parsed, schemaSha256 } : null; -} -function knowledgeSchemaRefV1(schema) { - return { - code: schema.code, - namespace: schema.namespace, - revision: schema.revision, - schemaSha256: schema.schemaSha256, - v: 1 - }; -} -function additiveBodyRetainsPrior(prior, next) { - return Object.entries(prior).every(([key, value]) => Object.hasOwn(next, key) && canonicalJson(next[key]) === canonicalJson(value)); -} -function verifyKnowledgeSchemaEvolutionV1(prior, next) { - if (parseKnowledgeSchemaRevisionV1(prior) === null || parseKnowledgeSchemaRevisionV1(next) === null) { - return { ok: false, reason: "invalid-schema" }; - } - if (prior.namespace !== next.namespace || prior.code !== next.code || prior.kind !== next.kind) { - return { ok: false, reason: "identity-changed" }; - } - if (next.revision !== prior.revision + 1 || next.previousSchemaSha256 !== prior.schemaSha256) { - return { ok: false, reason: "chain-broken" }; - } - if (next.compatibility === "additive" && !additiveBodyRetainsPrior(prior.body, next.body)) { - return { ok: false, reason: "false-additive-claim" }; - } - return { ok: true }; -} -function createKnowledgeVocabularyRevisionV1(input) { - const namespace = safeCode(input.namespace); - if (namespace === null || input.v !== 1 || !Number.isSafeInteger(input.revision) || input.revision < 1 || !Array.isArray(input.schemaRefs) || input.schemaRefs.length > 65536) { - throw new TypeError("Invalid vocabulary revision input."); - } - const refs = []; - for (const candidate of input.schemaRefs) { - const parsed = parseKnowledgeSchemaRefV1(candidate); - if (!parsed.ok || parsed.value.namespace !== namespace) - throw new TypeError("Invalid vocabulary schema reference."); - refs.push(parsed.value); - } - if (!orderedUnique(refs, canonicalJson)) - throw new TypeError("Vocabulary schema references must be ordered and unique."); - const payload = { namespace, revision: input.revision, schemaRefs: refs, v: 1 }; - return { ...payload, vocabularySha256: canonicalSha256(payload) }; -} -function parseKnowledgeVocabularyRevisionV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["namespace", "revision", "schemaRefs", "v", "vocabularySha256"])) - return null; - const digest = parseSha256Hex(value.vocabularySha256); - try { - const created = createKnowledgeVocabularyRevisionV1({ - namespace: value.namespace, - revision: value.revision, - schemaRefs: value.schemaRefs, - v: value.v - }); - return digest !== null && created.vocabularySha256 === digest ? { ...created, vocabularySha256: digest } : null; - } catch { - return null; - } -} // src/contract.ts var manifestPayload = Object.freeze({ @@ -1014,20 +288,15 @@ var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ ...manifestPayload, contractSha256: canonicalSha256(manifestPayload) }); -function parseOhContractManifestV1(value) { - try { - return canonicalJson(value) === canonicalJson(OH_CONTRACT_MANIFEST_V1) ? OH_CONTRACT_MANIFEST_V1 : null; - } catch { - return null; - } -} - class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1044,6 +313,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/projection.ts @@ -1062,10 +352,13 @@ var OH_PROJECTION_LIMITS_V1 = Object.freeze({ queryMatches: 262144, queryResults: 65536, relations: 4096, + resultBytes: 16 * 1024 * 1024, rounds: 1024, rules: 1024, sourcesPerFact: 64, - variables: 256 + totalProofNodes: 65536, + variables: 256, + workUnits: 16777216 }); var recordFactExtractorPayloadV1 = { factPackId: "oh.record-facts", @@ -1504,9 +797,15 @@ function createOhProjectionIdentityV1(input) { if (snapshot === null || dataset === null || query === null || rulePack === null) { throw new TypeError("Invalid projection identity input."); } + const engine = safeCode(input.engine ?? OH_PROJECTION_INTERNAL_ENGINE_V1, 256); + if (engine === null) + throw new TypeError("Invalid projection engine identity."); + const evaluation = { ...resolveEvaluationOptions(input.options ?? {}), v: 1 }; const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, datasetSha256: dataset.datasetSha256, + engineSha256: canonicalSha256({ engine, v: 1 }), + evaluationSha256: canonicalSha256(evaluation), querySha256: query.querySha256, rulePackSha256: rulePack.rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, @@ -1519,6 +818,8 @@ function parseOhProjectionIdentityV1(value) { if (!isPlainRecord(value) || !hasExactKeys(value, [ "contractSha256", "datasetSha256", + "engineSha256", + "evaluationSha256", "projectionSha256", "querySha256", "rulePackSha256", @@ -1529,15 +830,19 @@ function parseOhProjectionIdentityV1(value) { return null; const contractSha256 = parseSha256Hex(value.contractSha256); const datasetSha256 = parseSha256Hex(value.datasetSha256); + const engineSha256 = parseSha256Hex(value.engineSha256); + const evaluationSha256 = parseSha256Hex(value.evaluationSha256); const projectionSha256 = parseSha256Hex(value.projectionSha256); const querySha256 = parseSha256Hex(value.querySha256); const rulePackSha256 = parseSha256Hex(value.rulePackSha256); const snapshotSha256 = parseSha256Hex(value.snapshotSha256); - if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || engineSha256 === null || evaluationSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) return null; const payload = { contractSha256, datasetSha256, + engineSha256, + evaluationSha256, querySha256, rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, @@ -1558,6 +863,10 @@ function invalidationForOhProjectionV1(previous, next) { reasons.push("snapshot-changed"); if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) reasons.push("dataset-changed"); + if (parsedPrevious.engineSha256 !== parsedNext.engineSha256) + reasons.push("engine-changed"); + if (parsedPrevious.evaluationSha256 !== parsedNext.evaluationSha256) + reasons.push("evaluation-changed"); if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) reasons.push("rule-pack-changed"); if (parsedPrevious.querySha256 !== parsedNext.querySha256) @@ -1615,13 +924,19 @@ function unifyLiteral(literal, state, binding) { } return next; } -function matchBody(relations, body, maximumMatches) { +function consumeWorkUnit(budget) { + if (budget.units >= budget.maximum) + throw new RangeError("Projection exceeds its work-unit bound."); + budget.units += 1; +} +function matchBody(relations, body, maximumMatches, work) { let matches = [{ binding: new Map, premises: [] }]; for (const literal of body) { const next = []; const candidates = relationTuples(relations, literal.relation); for (const match of matches) { for (const candidate of candidates) { + consumeWorkUnit(work); const binding = unifyLiteral(literal, candidate, match.binding); if (binding === null) continue; @@ -1662,7 +977,7 @@ function materializeNaive(input) { while (true) { const candidates = new Map; for (const rule of input.rulePack.rules) { - for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches, input.work)) { const derivedTuple = instantiateHead(rule.head, match.binding); const relation = relations.get(rule.head.relation); const key = tupleKey(derivedTuple); @@ -1707,52 +1022,97 @@ function boundedOption(value, fallback, maximum, label) { return parsed; } function resolveEvaluationOptions(options) { - return { + const resolved = { maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), maximumProofNodes: boundedOption(options.maximumProofNodes, 1024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), - maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds") + maximumResultBytes: boundedOption(options.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, "maximumResultBytes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds"), + maximumTotalProofNodes: boundedOption(options.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, "maximumTotalProofNodes"), + maximumWorkUnits: boundedOption(options.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits, OH_PROJECTION_LIMITS_V1.workUnits, "maximumWorkUnits") }; -} -function proofForReference(relations, reference, budget, options, depth, visiting) { - if (budget.nodes >= options.maximumProofNodes) - return null; - if (budget.nodes === options.maximumProofNodes - 1) { - budget.nodes += 1; - return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; + if (resolved.maximumResultBytes < 64 * 1024) { + throw new RangeError("maximumResultBytes must be at least 65536."); } + return resolved; +} +function reserveResultBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function reserveProofNode(budget, options, envelope) { + if (budget.nodes >= options.maximumProofNodes || budget.result.nodes >= options.maximumTotalProofNodes || !reserveResultBytes(budget.result, envelope)) + return false; budget.nodes += 1; + budget.result.nodes += 1; + return true; +} +function proofForReference(relations, reference, budget, options, depth, visiting) { if (depth >= options.maximumProofDepth) { - return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { + kind: "truncated", + reason: "depth", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; } const identity = referenceKey(reference); if (visiting.has(identity)) { - return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { + kind: "truncated", + reason: "cycle", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; } const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); if (state === undefined) throw new Error("Projection proof references a tuple outside the materialized result."); if (state.witness.kind === "fact") { - return { + const proof = { kind: "fact", relation: reference.relation, sources: state.witness.sources, tuple: reference.tuple, v: 1 }; - } + return reserveProofNode(budget, options, proof) ? proof : null; + } + const envelope = { + kind: "derived", + premises: [], + premisesTruncated: false, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + if (!reserveProofNode(budget, options, envelope)) + return null; visiting.add(identity); try { const premises = []; + let premisesTruncated = false; for (const premise of state.witness.premises) { const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); - if (proof === null) + if (proof === null) { + premisesTruncated = true; break; + } premises.push(proof); } return { kind: "derived", premises, + premisesTruncated, relation: reference.relation, ruleId: state.witness.rule.ruleId, ruleSha256: state.witness.rule.ruleSha256, @@ -1763,33 +1123,334 @@ function proofForReference(relations, reference, budget, options, depth, visitin visiting.delete(identity); } } +function proofIsTruncated(proof) { + return proof.kind === "truncated" || proof.kind === "derived" && (proof.premisesTruncated || proof.premises.some(proofIsTruncated)); +} +function reserveProjectionParseBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function parseProjectionFactSource(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["key", "recordSha256", "v"]) || value.v !== 1) { + return null; + } + const key = safeCode(value.key, 512); + const recordSha256 = parseSha256Hex(value.recordSha256); + return key === null || recordSha256 === null ? null : { key, recordSha256, v: 1 }; +} +function parseProjectionProofWithBudget(value, budget, depth) { + if (depth > budget.maximumDepth || budget.nodes >= budget.maximumNodes || !isPlainRecord(value) || value.v !== 1) + return null; + const relation = projectionName(value.relation); + const parsedTuple = tuple(value.tuple); + if (relation === null || parsedTuple === null) + return null; + if (value.kind === "fact") { + if (!hasExactKeys(value, ["kind", "relation", "sources", "tuple", "v"]) || !Array.isArray(value.sources) || value.sources.length < 1 || value.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) + return null; + const sources = value.sources.map(parseProjectionFactSource); + if (sources.some((source) => source === null)) + return null; + const parsedSources = sources; + if (!orderedUnique(parsedSources, (source) => source.key)) + return null; + const proof = { + kind: "fact", + relation, + sources: parsedSources, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind === "truncated") { + if (!hasExactKeys(value, ["kind", "reason", "relation", "tuple", "v"]) || value.reason !== "cycle" && value.reason !== "depth" && value.reason !== "nodes") + return null; + const reason = value.reason; + const proof = { + kind: "truncated", + reason, + relation, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind !== "derived" || !hasExactKeys(value, [ + "kind", + "premises", + "premisesTruncated", + "relation", + "ruleId", + "ruleSha256", + "tuple", + "v" + ]) || !Array.isArray(value.premises) || value.premises.length > OH_PROJECTION_LIMITS_V1.literalsPerRule || typeof value.premisesTruncated !== "boolean") + return null; + const ruleId = projectionName(value.ruleId); + const ruleSha256 = parseSha256Hex(value.ruleSha256); + if (ruleId === null || ruleSha256 === null || !value.premisesTruncated && value.premises.length === 0 || value.premisesTruncated && value.premises.length === OH_PROJECTION_LIMITS_V1.literalsPerRule) { + return null; + } + const skeleton = { + kind: "derived", + premises: [], + premisesTruncated: value.premisesTruncated, + relation, + ruleId, + ruleSha256, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, skeleton)) + return null; + budget.nodes += 1; + const premises = []; + for (const premise of value.premises) { + const parsed = parseProjectionProofWithBudget(premise, budget, depth + 1); + if (parsed === null) + return null; + premises.push(parsed); + } + return { ...skeleton, premises }; +} +function parseOhProjectionProofV1(value) { + try { + const budget = { + bytes: 0, + maximumBytes: OH_PROJECTION_LIMITS_V1.resultBytes, + maximumDepth: OH_PROJECTION_LIMITS_V1.proofDepth, + maximumNodes: OH_PROJECTION_LIMITS_V1.proofNodes, + nodes: 0 + }; + const proof = parseProjectionProofWithBudget(value, budget, 0); + return proof !== null && utf8ByteLength(canonicalJson(proof)) <= budget.maximumBytes ? proof : null; + } catch { + return null; + } +} +function parseProjectionEvaluation(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "maximumDerivedTuples", + "maximumProofDepth", + "maximumProofNodes", + "maximumResultBytes", + "maximumRounds", + "maximumTotalProofNodes", + "maximumWorkUnits", + "v" + ]) || value.v !== 1) + return null; + const maximumDerivedTuples = positiveInteger(value.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples); + const maximumProofDepth = positiveInteger(value.maximumProofDepth, OH_PROJECTION_LIMITS_V1.proofDepth); + const maximumProofNodes = positiveInteger(value.maximumProofNodes, OH_PROJECTION_LIMITS_V1.proofNodes); + const maximumResultBytes = positiveInteger(value.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes); + const maximumRounds = positiveInteger(value.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds); + const maximumTotalProofNodes = positiveInteger(value.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes); + const maximumWorkUnits = positiveInteger(value.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits); + if (maximumDerivedTuples === null || maximumProofDepth === null || maximumProofNodes === null || maximumResultBytes === null || maximumResultBytes < 64 * 1024 || maximumRounds === null || maximumTotalProofNodes === null || maximumWorkUnits === null) + return null; + return { + maximumDerivedTuples, + maximumProofDepth, + maximumProofNodes, + maximumResultBytes, + maximumRounds, + maximumTotalProofNodes, + maximumWorkUnits, + v: 1 + }; +} +function parseProjectionResultRow(value, evaluation, resultBudget) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["proofs", "proofsTruncated", "supportCount", "values", "v"]) || value.v !== 1 || !Array.isArray(value.proofs) || value.proofs.length > OH_PROJECTION_LIMITS_V1.queryLiterals || typeof value.proofsTruncated !== "boolean") + return null; + const values = tuple(value.values); + const supportCount = positiveInteger(value.supportCount, OH_PROJECTION_LIMITS_V1.queryMatches); + if (values === null || supportCount === null || !value.proofsTruncated && value.proofs.length === 0) + return null; + if (!reserveProjectionParseBytes(resultBudget, { + proofs: [], + proofsTruncated: value.proofsTruncated, + supportCount, + values, + v: 1 + })) + return null; + const before = resultBudget.nodes; + resultBudget.maximumNodes = Math.min(resultBudget.maximumNodes, before + evaluation.maximumProofNodes); + const proofs = []; + for (const proof of value.proofs) { + const parsed = parseProjectionProofWithBudget(proof, resultBudget, 0); + if (parsed === null) + return null; + proofs.push(parsed); + } + resultBudget.maximumNodes = evaluation.maximumTotalProofNodes; + const containsTruncation = proofs.some(proofIsTruncated); + if (!value.proofsTruncated && containsTruncation || value.proofsTruncated && proofs.length === OH_PROJECTION_LIMITS_V1.queryLiterals && !containsTruncation) + return null; + return { + nodes: resultBudget.nodes - before, + row: { proofs, proofsTruncated: value.proofsTruncated, supportCount, values, v: 1 } + }; +} +function parseOhProjectionResultV1(value, expectedProjectionSha256) { + try { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "authority", + "cache", + "engine", + "evaluation", + "identity", + "resultSha256", + "rows", + "stats", + "v" + ]) || value.v !== 1 || value.authority !== "derived" || !isPlainRecord(value.cache) || !hasExactKeys(value.cache, ["strategy", "v"]) || value.cache.strategy !== "full-rebuild" || value.cache.v !== 1 || !Array.isArray(value.rows) || value.rows.length > OH_PROJECTION_LIMITS_V1.queryResults || !isPlainRecord(value.stats) || !hasExactKeys(value.stats, [ + "baseFacts", + "derivedFacts", + "proofNodes", + "proofsTruncated", + "queryMatches", + "relations", + "rounds", + "truncated", + "truncationReasons", + "v", + "workUnits" + ]) || value.stats.v !== 1 || !Array.isArray(value.stats.truncationReasons) || typeof value.stats.proofsTruncated !== "boolean" || typeof value.stats.truncated !== "boolean") + return null; + const engine = safeCode(value.engine, 256); + const evaluation = parseProjectionEvaluation(value.evaluation); + const identity = parseOhProjectionIdentityV1(value.identity); + const resultSha256 = parseSha256Hex(value.resultSha256); + const expected = expectedProjectionSha256 === undefined ? undefined : parseSha256Hex(expectedProjectionSha256); + if (engine === null || evaluation === null || identity === null || resultSha256 === null || expectedProjectionSha256 !== undefined && expected === null || expected !== undefined && identity.projectionSha256 !== expected || identity.engineSha256 !== canonicalSha256({ engine, v: 1 }) || identity.evaluationSha256 !== canonicalSha256(evaluation)) + return null; + const baseFacts = nonnegativeInteger(value.stats.baseFacts); + const derivedFacts = nonnegativeInteger(value.stats.derivedFacts); + const proofNodes = nonnegativeInteger(value.stats.proofNodes); + const queryMatches = nonnegativeInteger(value.stats.queryMatches); + const relations = nonnegativeInteger(value.stats.relations); + const rounds = nonnegativeInteger(value.stats.rounds); + const workUnits = nonnegativeInteger(value.stats.workUnits); + if (baseFacts === null || baseFacts > OH_PROJECTION_LIMITS_V1.facts || derivedFacts === null || derivedFacts > evaluation.maximumDerivedTuples || proofNodes === null || proofNodes > evaluation.maximumTotalProofNodes || queryMatches === null || queryMatches > OH_PROJECTION_LIMITS_V1.queryMatches || relations === null || relations > OH_PROJECTION_LIMITS_V1.relations || rounds === null || rounds > evaluation.maximumRounds || workUnits === null || workUnits > evaluation.maximumWorkUnits || relations > baseFacts + derivedFacts || rounds > derivedFacts || rounds === 0 !== (derivedFacts === 0) || queryMatches > workUnits) + return null; + const truncationReasons = value.stats.truncationReasons; + if (truncationReasons.length > 2 || !orderedUnique(truncationReasons, (reason) => reason === "query-limit" ? "0" : reason === "result-bytes" ? "1" : "x") || truncationReasons.some((reason) => reason !== "query-limit" && reason !== "result-bytes") || value.stats.truncated !== truncationReasons.length > 0) + return null; + const budget = { + bytes: 0, + maximumBytes: evaluation.maximumResultBytes, + maximumDepth: evaluation.maximumProofDepth, + maximumNodes: evaluation.maximumTotalProofNodes, + nodes: 0 + }; + const rows = []; + let supportCount = 0; + for (const row of value.rows) { + const parsed = parseProjectionResultRow(row, evaluation, budget); + if (parsed === null) + return null; + rows.push(parsed.row); + supportCount += parsed.row.supportCount; + if (supportCount > queryMatches) + return null; + } + if (!orderedUnique(rows, (row) => canonicalJson(row.values)) || budget.nodes !== proofNodes || value.stats.proofsTruncated !== rows.some((row) => row.proofsTruncated) || (value.stats.truncated ? supportCount >= queryMatches : supportCount !== queryMatches)) + return null; + const reasons = truncationReasons; + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine, + evaluation, + identity, + rows, + stats: { + baseFacts, + derivedFacts, + proofNodes, + proofsTruncated: value.stats.proofsTruncated, + queryMatches, + relations, + rounds, + truncated: value.stats.truncated, + truncationReasons: reasons, + v: 1, + workUnits + }, + v: 1 + }; + const serialized = canonicalJson(payload); + return utf8ByteLength(serialized) <= evaluation.maximumResultBytes && sha256Hex(serialized) === resultSha256 ? { ...payload, resultSha256 } : null; + } catch { + return null; + } +} function buildProjectionResult(input) { - const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches); + const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches, input.work); const byValues = new Map; for (const match of matches) { const values = input.query.find.map((name) => match.binding.get(name)); const key = tupleKey(values); const existing = byValues.get(key); - if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) - byValues.set(key, match); + if (existing === undefined) + byValues.set(key, { match, supportCount: 1 }); + else + byValues.set(key, { match: compareCanonical(match.premises, existing.match.premises) < 0 ? match : existing.match, supportCount: existing.supportCount + 1 }); } const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); - const truncated = ordered.length > input.query.limit; - const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const resultBudget = { + bytes: 0, + maximumBytes: input.options.maximumResultBytes - 64 * 1024, + nodes: 0 + }; + const rows = []; + let resultBytesTruncated = false; + for (const [key, support] of ordered.slice(0, input.query.limit)) { const values = JSON.parse(key); - const budget = { nodes: 0 }; + if (!reserveResultBytes(resultBudget, { + proofs: [], + proofsTruncated: false, + supportCount: support.supportCount, + values, + v: 1 + })) { + resultBytesTruncated = true; + break; + } + const budget = { nodes: 0, result: resultBudget }; const proofs = []; - for (const premise of match.premises) { + for (const premise of support.match.premises) { const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set); if (proof === null) break; proofs.push(proof); } - return { proofs, values, v: 1 }; - }); + const proofsTruncated = proofs.length !== support.match.premises.length || proofs.some(proofIsTruncated); + rows.push({ proofs, proofsTruncated, supportCount: support.supportCount, values, v: 1 }); + } + const queryLimitTruncated = ordered.length > input.query.limit; + const truncationReasons = [ + ...queryLimitTruncated ? ["query-limit"] : [], + ...resultBytesTruncated ? ["result-bytes"] : [] + ]; + const truncated = truncationReasons.length > 0; const identity = createOhProjectionIdentityV1({ dataset: input.dataset, query: input.query, + engine: input.engine, + options: input.options, rulePack: input.rulePack, snapshot: input.snapshot }); @@ -1803,15 +1464,23 @@ function buildProjectionResult(input) { stats: { baseFacts: input.materialized.baseFacts, derivedFacts: input.materialized.derivedFacts, + proofNodes: resultBudget.nodes, + proofsTruncated: rows.some((row) => row.proofsTruncated), queryMatches: matches.length, relations: input.materialized.relations.size, rounds: input.materialized.rounds, truncated, - v: 1 + truncationReasons, + v: 1, + workUnits: input.work.units }, v: 1 }; - return { ...payload, resultSha256: canonicalSha256(payload) }; + const serialized = canonicalJson(payload); + if (utf8ByteLength(serialized) > input.options.maximumResultBytes) { + throw new RangeError("Projection result exceeds its canonical byte bound."); + } + return { ...payload, resultSha256: sha256Hex(serialized) }; } function evaluateOhProjectionV1(input) { const snapshot = parseOhProjectionSnapshotV1(input.snapshot); @@ -1823,11 +1492,13 @@ function evaluateOhProjectionV1(input) { } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; const materialized = materializeNaive({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, - rulePack + rulePack, + work }); return buildProjectionResult({ dataset, @@ -1836,7 +1507,8 @@ function evaluateOhProjectionV1(input) { options, query, rulePack, - snapshot + snapshot, + work }); } function evaluateOhProjectionWithMaterializerV1(input) { @@ -1850,17 +1522,19 @@ function evaluateOhProjectionWithMaterializerV1(input) { } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); - const external = input.materialize({ + const work = { maximum: options.maximumWorkUnits, units: 0 }; + const witnessMaterialization = materializeNaive({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, - query, - rulePack + rulePack, + work }); - const witnessMaterialization = materializeNaive({ + const external = input.materialize({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, + query, rulePack }); const externalCanonical = new Map; @@ -1894,17 +1568,31 @@ function evaluateOhProjectionWithMaterializerV1(input) { options, query, rulePack, - snapshot + snapshot, + work }); } function createOhProjectionRecordFactsV1(records, options = {}) { if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) throw new RangeError("Too many records for projection facts."); - const facts = []; - for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { + const parsedRecords = [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0).map((candidate) => { const record = parseKnowledgeGraphRecordV1(candidate); if (record === null) throw new TypeError("Invalid graph record for projection facts."); + return record; + }); + let projectedFactCount = 0; + for (const record of parsedRecords) { + if (options.includeRecords !== false) + projectedFactCount += 1; + if (options.includeDependencies !== false) + projectedFactCount += record.dependencies.length; + if (projectedFactCount > OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("Structural projection exceeds its fact bound."); + } + } + const facts = []; + for (const record of parsedRecords) { const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 }]; if (options.includeRecords !== false) { facts.push(createOhProjectionFactV1({ @@ -1953,7 +1641,9 @@ var parseOhProjectionDatasetV12 = parseOhProjectionDatasetV1; var parseOhProjectionFactV12 = parseOhProjectionFactV1; var parseOhProjectionIdentityV12 = parseOhProjectionIdentityV1; var parseOhProjectionLiteralV12 = parseOhProjectionLiteralV1; +var parseOhProjectionProofV12 = parseOhProjectionProofV1; var parseOhProjectionQueryV12 = parseOhProjectionQueryV1; +var parseOhProjectionResultV12 = parseOhProjectionResultV1; var parseOhProjectionRulePackV12 = parseOhProjectionRulePackV1; var parseOhProjectionRuleV12 = parseOhProjectionRuleV1; var parseOhProjectionSnapshotV12 = parseOhProjectionSnapshotV1; @@ -1963,7 +1653,9 @@ export { parseOhProjectionSnapshotV12 as parseOhProjectionSnapshotV1, parseOhProjectionRuleV12 as parseOhProjectionRuleV1, parseOhProjectionRulePackV12 as parseOhProjectionRulePackV1, + parseOhProjectionResultV12 as parseOhProjectionResultV1, parseOhProjectionQueryV12 as parseOhProjectionQueryV1, + parseOhProjectionProofV12 as parseOhProjectionProofV1, parseOhProjectionLiteralV12 as parseOhProjectionLiteralV1, parseOhProjectionIdentityV12 as parseOhProjectionIdentityV1, parseOhProjectionFactV12 as parseOhProjectionFactV1, diff --git a/dist/projection-suss.d.ts.map b/dist/projection-suss.d.ts.map index 4705af7..9eb582a 100644 --- a/dist/projection-suss.d.ts.map +++ b/dist/projection-suss.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"projection-suss.d.ts","sourceRoot":"","sources":["../src/projection-suss.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EAEpC,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAE5B,MAAM,cAAc,CAAC;AAEtB,eAAO,MAAM,6BAA6B,EAAG,QAAiB,CAAC;AAC/D,eAAO,MAAM,4BAA4B,EAAG,kCAA2C,CAAC;AA8DxF;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC7D,OAAO,EAAE,qBAAqB,CAAC;IAC/B,OAAO,CAAC,EAAE,+BAA+B,CAAC;IAC1C,KAAK,EAAE,mBAAmB,CAAC;IAC3B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;CAClC,CAAC,GAAG,oBAAoB,CAsBxB"} \ No newline at end of file +{"version":3,"file":"projection-suss.d.ts","sourceRoot":"","sources":["../src/projection-suss.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EAEpC,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAE5B,MAAM,cAAc,CAAC;AAEtB,eAAO,MAAM,6BAA6B,EAAG,QAAiB,CAAC;AAC/D,eAAO,MAAM,4BAA4B,EAAG,kCAA2C,CAAC;AAoExF;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC7D,OAAO,EAAE,qBAAqB,CAAC;IAC/B,OAAO,CAAC,EAAE,+BAA+B,CAAC;IAC1C,KAAK,EAAE,mBAAmB,CAAC;IAC3B,QAAQ,EAAE,sBAAsB,CAAC;IACjC,QAAQ,EAAE,sBAAsB,CAAC;CAClC,CAAC,GAAG,oBAAoB,CAsBxB"} \ No newline at end of file diff --git a/dist/projection-suss.js b/dist/projection-suss.js index c5bc62b..d82727e 100644 --- a/dist/projection-suss.js +++ b/dist/projection-suss.js @@ -1,4 +1,3 @@ -// @bun // src/projection-suss.ts import { Database, @@ -10,7 +9,7 @@ import { } from "@suss/datalog"; // src/canonical.ts -import { createHash, randomBytes } from "crypto"; +import { createHash, randomBytes } from "node:crypto"; class OhValidationError extends Error { code; @@ -148,30 +147,9 @@ function parseCanonicalInstantV1(value) { function canonicalNow() { return new Date().toISOString(); } -function opaqueId(prefix) { - if (!/^[a-z][a-z0-9_]{1,15}$/u.test(prefix)) { - throw new OhValidationError("invalid-prefix", "prefix", "must be a short lowercase code"); - } - return `${prefix}${randomBytes(12).toString("hex")}`; -} function safeCode(value, maximumLength = 128) { return typeof value === "string" && value.length <= maximumLength && /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value) ? value : null; } -function boundedText(value, maximumBytes = 64 * 1024) { - if (typeof value !== "string" || value.length === 0 || value.normalize("NFC") !== value || utf8ByteLength(value) > maximumBytes) - return null; - try { - assertUnicodeScalarString(value, "$text"); - } catch { - return null; - } - for (const character of value) { - const code = character.codePointAt(0) ?? 0; - if (code <= 8 || code >= 11 && code <= 12 || code >= 14 && code <= 31 || code >= 127 && code <= 159) - return null; - } - return value; -} function orderedUnique(values, key) { return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value)); } @@ -292,128 +270,6 @@ function graphRevisionSha256V1(input) { } return canonicalSha256({ changes, operationId, parentGraphRevisionSha256, recordsSha256, revision, v: 1 }); } -function createKnowledgeGraphRevisionV1(input) { - if (input.parent !== null && parseKnowledgeGraphRevisionV1(input.parent) === null) { - throw new TypeError("Invalid parent graph revision."); - } - const operationId = safeCode(input.operationId); - const changes = canonicalKnowledgeGraphChangesV1(input.changes); - if (operationId === null || changes.length === 0 || changes.length > OH_GRAPH_LIMITS_V1.changesPerOperation) - throw new TypeError("Invalid graph revision."); - const byKey = new Map((input.parent?.recordRefs ?? []).map((ref) => [ref.key, ref])); - for (const change of changes) { - if (change.kind === "put") { - for (const dependency of change.record.dependencies) { - if (!byKey.has(dependency) && !changes.some((candidate) => candidate.kind === "put" && candidate.record.key === dependency)) { - throw new TypeError(`Missing graph dependency: ${dependency}`); - } - } - byKey.set(change.record.key, knowledgeGraphRecordRefV1(change.record)); - } else { - const prior = byKey.get(change.key); - if (prior === undefined || prior.sha256 !== change.priorSha256) - throw new TypeError("Tombstone prior digest does not match."); - byKey.delete(change.key); - } - } - if (byKey.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { - throw new RangeError("Graph revision exceeds its record snapshot limit."); - } - for (const ref of byKey.values()) { - if (ref.dependencies.some((dependency) => !byKey.has(dependency))) { - throw new TypeError(`Missing graph dependency after revision: ${ref.key}`); - } - } - const recordRefs = [...byKey.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); - const recordsSha256 = canonicalSha256(recordRefs); - const payload = { - changes, - operationId, - parentGraphRevisionSha256: input.parent?.graphRevisionSha256 ?? null, - recordRefs, - recordsSha256, - revision: (input.parent?.revision ?? 0) + 1, - v: 1 - }; - return { ...payload, graphRevisionSha256: graphRevisionSha256V1(payload) }; -} -function parseKnowledgeGraphRevisionV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "changes", - "graphRevisionSha256", - "operationId", - "parentGraphRevisionSha256", - "recordRefs", - "recordsSha256", - "revision", - "v" - ]) || value.v !== 1 || !Array.isArray(value.changes) || !Array.isArray(value.recordRefs) || value.recordRefs.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) - return null; - const graphRevisionSha256 = parseSha256Hex(value.graphRevisionSha256); - const recordsSha256 = parseSha256Hex(value.recordsSha256); - const parentGraphRevisionSha256 = value.parentGraphRevisionSha256 === null ? null : parseSha256Hex(value.parentGraphRevisionSha256); - const operationId = safeCode(value.operationId); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - let changes; - try { - changes = canonicalKnowledgeGraphChangesV1(value.changes); - } catch { - return null; - } - const refs = []; - for (const item of value.recordRefs) { - if (!isPlainRecord(item) || !hasExactKeys(item, ["dependencies", "key", "kind", "sha256", "v"]) || item.v !== 1 || !Array.isArray(item.dependencies)) - return null; - const dependencies = item.dependencies.map(recordKey); - const key = recordKey(item.key); - const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === item.kind); - const sha256 = parseSha256Hex(item.sha256); - if (key === null || kind === undefined || sha256 === null || dependencies.some((dependency) => dependency === null) || dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord || !orderedUnique(dependencies, String) || dependencies.includes(key)) - return null; - refs.push({ dependencies, key, kind, sha256, v: 1 }); - } - if (graphRevisionSha256 === null || recordsSha256 === null || operationId === null || revision === null || value.parentGraphRevisionSha256 !== null && parentGraphRevisionSha256 === null || !orderedUnique(refs, (ref) => ref.key) || canonicalSha256(refs) !== recordsSha256) - return null; - const keys = new Set(refs.map((ref) => ref.key)); - if (refs.some((ref) => ref.dependencies.some((dependency) => !keys.has(dependency)))) - return null; - const payload = { - changes, - operationId, - parentGraphRevisionSha256, - recordRefs: refs, - recordsSha256, - revision, - v: 1 - }; - try { - return graphRevisionSha256V1(payload) === graphRevisionSha256 ? { ...payload, graphRevisionSha256 } : null; - } catch { - return null; - } -} -function reduceKnowledgeGraphRevisionsV1(revisions) { - if (revisions.length === 0 || revisions.length > 65536) - return null; - const ordered = [...revisions].sort((left, right) => left.revision - right.revision); - let parent = null; - const operationIds = new Set; - for (const candidate of ordered) { - const current = parseKnowledgeGraphRevisionV1(candidate); - if (current === null || current.revision !== (parent?.revision ?? 0) + 1 || current.parentGraphRevisionSha256 !== (parent?.graphRevisionSha256 ?? null) || operationIds.has(current.operationId)) - return null; - try { - const rebuilt = createKnowledgeGraphRevisionV1({ changes: current.changes, operationId: current.operationId, parent }); - if (rebuilt.graphRevisionSha256 !== current.graphRevisionSha256) - return null; - } catch { - return null; - } - operationIds.add(current.operationId); - parent = current; - } - return parent; -} // src/ontology.ts var OH_ONTOLOGY_VERSION_V1 = "1.0.0"; @@ -425,591 +281,9 @@ var OH_KNOWLEDGE_LIMITS_V1 = Object.freeze({ statementBytes: 256 * 1024, textBytes: 64 * 1024 }); -var OH_KNOWLEDGE_KERNEL_CONCEPTS_V1 = [ - { code: "entity", description: "A stable identity anchor for something that can be referred to.", label: "Entity" }, - { code: "statement", description: "An immutable proposition with a subject, predicate, object, and qualifiers.", label: "Statement" }, - { code: "assertion", description: "An attributable stance toward a statement.", label: "Assertion" }, - { code: "evidence", description: "A typed account of how an observation bears on an assertion.", label: "Evidence" }, - { code: "context", description: "The scenario and dimensions in which knowledge applies.", label: "Context" }, - { code: "inquiry", description: "A question and its durable investigation trail.", label: "Inquiry" }, - { code: "projection", description: "A reproducible view derived from exact knowledge.", label: "Projection" } -]; -function success(value) { - return { ok: true, value }; -} -function failure(field, code = "invalid-input") { - return { error: { code, field }, ok: false }; -} -function parseOpaqueId(value, prefix) { - return typeof value === "string" && new RegExp(`^${prefix}[a-z0-9]{24}$`, "u").test(value) ? value : null; -} -function parseKnowledgeEntityId(value) { - return parseOpaqueId(value, "kent_"); -} -function parseKnowledgeAssertionId(value) { - return parseOpaqueId(value, "kast_"); -} -function parseKnowledgeEvidenceId(value) { - return parseOpaqueId(value, "kevd_"); -} -function parseKnowledgeInquiryId(value) { - return parseOpaqueId(value, "kinq_"); -} -var OH_KNOWLEDGE_ENTITY_STATES_V1 = ["active", "quarantined", "redirected", "tombstoned"]; -function parseKnowledgeEntityV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "entityId", - "identityOperationId", - "identityRevision", - "redirectEntityId", - "state", - "v" - ]) || value.v !== 1) - return failure("entity"); - const entityId = parseKnowledgeEntityId(value.entityId); - const identityOperationId = safeCode(value.identityOperationId); - const identityRevision = Number.isSafeInteger(value.identityRevision) && value.identityRevision > 0 ? value.identityRevision : null; - const redirectEntityId = value.redirectEntityId === null ? null : parseKnowledgeEntityId(value.redirectEntityId); - const state = OH_KNOWLEDGE_ENTITY_STATES_V1.find((candidate) => candidate === value.state); - return entityId !== null && identityOperationId !== null && identityRevision !== null && (value.redirectEntityId === null || redirectEntityId !== null) && state !== undefined && state === "redirected" === (redirectEntityId !== null) && redirectEntityId !== entityId ? success({ entityId, identityOperationId, identityRevision, redirectEntityId, state, v: 1 }) : failure("entity"); -} -function parseKnowledgeSchemaRefV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["code", "namespace", "revision", "schemaSha256", "v"]) || value.v !== 1) - return failure("schemaRef"); - const code = safeCode(value.code); - const namespace = safeCode(value.namespace); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - const schemaSha256 = parseSha256Hex(value.schemaSha256); - return code !== null && namespace !== null && revision !== null && schemaSha256 !== null ? success({ code, namespace, revision, schemaSha256, v: 1 }) : failure("schemaRef"); -} -var INTEGER = /^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$/u; -var DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/u; -function parseKnowledgeValueInternal(value, depth) { - if (!isPlainRecord(value) || value.v !== 1 || depth > 8) - return null; - switch (value.kind) { - case "entity": { - if (!hasExactKeys(value, ["entityId", "kind", "v"])) - return null; - const entityId = parseKnowledgeEntityId(value.entityId); - return entityId === null ? null : { entityId, kind: "entity", v: 1 }; - } - case "text": { - if (!hasExactKeys(value, ["kind", "language", "text", "v"])) - return null; - const language = typeof value.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(value.language) ? value.language : null; - const text = boundedText(value.text); - return language !== null && text !== null ? { kind: "text", language, text, v: 1 } : null; - } - case "string": { - const parsed = boundedText(value.value); - return hasExactKeys(value, ["kind", "v", "value"]) && parsed !== null ? { kind: "string", v: 1, value: parsed } : null; - } - case "boolean": - return hasExactKeys(value, ["kind", "v", "value"]) && typeof value.value === "boolean" ? { kind: "boolean", v: 1, value: value.value } : null; - case "integer": - case "decimal": { - const valid = typeof value.value === "string" && value.value.length <= 1024 && (value.kind === "integer" ? INTEGER.test(value.value) : DECIMAL.test(value.value) && value.value !== "-0"); - return hasExactKeys(value, ["kind", "v", "value"]) && valid ? { kind: value.kind, v: 1, value: value.value } : null; - } - case "uri": { - if (!hasExactKeys(value, ["kind", "uri", "v"]) || typeof value.uri !== "string" || value.uri.length > 4096) - return null; - try { - const url = new URL(value.uri); - return url.href === value.uri && url.username === "" && url.password === "" && !["data:", "file:", "javascript:"].includes(url.protocol) ? { kind: "uri", uri: value.uri, v: 1 } : null; - } catch { - return null; - } - } - case "list": - case "set": { - if (!hasExactKeys(value, ["kind", "v", "values"]) || !Array.isArray(value.values) || value.values.length > OH_KNOWLEDGE_LIMITS_V1.listValues) - return null; - const values = []; - for (const item of value.values) { - const parsed = parseKnowledgeValueInternal(item, depth + 1); - if (parsed === null) - return null; - values.push(parsed); - } - if (value.kind === "set" && !orderedUnique(values, canonicalJson)) - return null; - return { kind: value.kind, v: 1, values }; - } - case "extension": { - if (!hasExactKeys(value, ["canonicalizerSha256", "canonicalValue", "kind", "mediaType", "schema", "v", "valueSha256"])) - return null; - const canonicalizerSha256 = parseSha256Hex(value.canonicalizerSha256); - const canonicalValue = boundedText(value.canonicalValue, 64 * 1024); - const mediaType = typeof value.mediaType === "string" && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/u.test(value.mediaType) ? value.mediaType : null; - const schema = parseKnowledgeSchemaRefV1(value.schema); - const valueSha256 = parseSha256Hex(value.valueSha256); - return canonicalizerSha256 !== null && canonicalValue !== null && mediaType !== null && schema.ok && valueSha256 !== null ? { canonicalizerSha256, canonicalValue, kind: "extension", mediaType, schema: schema.value, v: 1, valueSha256 } : null; - } - default: - return null; - } -} -function parseKnowledgeValueV1(value) { - const parsed = parseKnowledgeValueInternal(value, 0); - return parsed === null ? failure("value") : success(parsed); -} -function verifyKnowledgeValueV1(value) { - const parsed = parseKnowledgeValueV1(value); - if (!parsed.ok) - return parsed; - if (parsed.value.kind === "extension" && sha256Hex(parsed.value.canonicalValue) !== parsed.value.valueSha256) { - return failure("valueSha256", "digest-mismatch"); - } - if (parsed.value.kind === "list" || parsed.value.kind === "set") { - for (const child of parsed.value.values) { - const verified = verifyKnowledgeValueV1(child); - if (!verified.ok) - return verified; - } - } - return success(parsed.value); -} -function parseDimension(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["predicate", "v", "value"]) || value.v !== 1) - return null; - const predicate = parseKnowledgeSchemaRefV1(value.predicate); - const parsedValue = parseKnowledgeValueV1(value.value); - return predicate.ok && parsedValue.ok ? { predicate: predicate.value, v: 1, value: parsedValue.value } : null; -} -function createKnowledgeContextV1(input) { - if (!isPlainRecord(input) || input.v !== 1 || !Array.isArray(input.dimensions) || input.dimensions.length > OH_KNOWLEDGE_LIMITS_V1.dimensions || !["actual", "counterfactual", "hypothetical", "planned"].includes(input.scenario)) - return failure("context"); - const dimensions = []; - for (const item of input.dimensions) { - const parsed = parseDimension(item); - if (parsed === null) - return failure("dimensions"); - const verified = verifyKnowledgeValueV1(parsed.value); - if (!verified.ok) - return verified; - dimensions.push(parsed); - } - let canonicalDimensions; - try { - canonicalDimensions = sortUnique(dimensions, canonicalJson); - } catch { - return failure("dimensions", "noncanonical-input"); - } - const payload = { dimensions: canonicalDimensions, scenario: input.scenario, v: 1 }; - return success({ ...payload, contextSha256: canonicalSha256(payload) }); -} -function parseKnowledgeContextV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["contextSha256", "dimensions", "scenario", "v"])) - return failure("context"); - const digest = parseSha256Hex(value.contextSha256); - if (digest === null) - return failure("contextSha256"); - const created = createKnowledgeContextV1({ dimensions: value.dimensions, scenario: value.scenario, v: value.v }); - return created.ok && created.value.contextSha256 === digest && canonicalJson(created.value.dimensions) === canonicalJson(value.dimensions) ? success({ ...created.value, contextSha256: digest }) : failure("contextSha256", "digest-mismatch"); -} -function createKnowledgeStatementV1(input) { - const object = parseKnowledgeValueV1(input.object); - const predicate = parseKnowledgeSchemaRefV1(input.predicate); - const subject = parseKnowledgeEntityId(input.subject); - if (input.v !== 1 || !object.ok || !predicate.ok || subject === null || !Array.isArray(input.qualifiers) || input.qualifiers.length > OH_KNOWLEDGE_LIMITS_V1.qualifiers) - return failure("statement"); - const verifiedObject = verifyKnowledgeValueV1(object.value); - if (!verifiedObject.ok) - return verifiedObject; - const qualifiers = []; - for (const item of input.qualifiers) { - const parsed = parseDimension(item); - if (parsed === null) - return failure("qualifiers"); - const verified = verifyKnowledgeValueV1(parsed.value); - if (!verified.ok) - return verified; - qualifiers.push(parsed); - } - let canonicalQualifiers; - try { - canonicalQualifiers = sortUnique(qualifiers, canonicalJson); - } catch { - return failure("qualifiers", "noncanonical-input"); - } - const payload = { object: object.value, predicate: predicate.value, qualifiers: canonicalQualifiers, subject, v: 1 }; - if (Buffer.byteLength(canonicalJson(payload), "utf8") > OH_KNOWLEDGE_LIMITS_V1.statementBytes) - return failure("statement", "limit-exceeded"); - return success({ ...payload, statementSha256: canonicalSha256(payload) }); -} -function parseKnowledgeStatementV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["object", "predicate", "qualifiers", "statementSha256", "subject", "v"])) - return failure("statement"); - const digest = parseSha256Hex(value.statementSha256); - const created = createKnowledgeStatementV1(value); - return digest !== null && created.ok && created.value.statementSha256 === digest && canonicalJson(created.value.qualifiers) === canonicalJson(value.qualifiers) ? success({ ...created.value, statementSha256: digest }) : failure("statementSha256", "digest-mismatch"); -} -function parseKnowledgeAgentRefV1(value) { - if (!isPlainRecord(value) || value.v !== 1) - return null; - if (value.kind === "entity" && hasExactKeys(value, ["entityId", "kind", "v"])) { - const entityId = parseKnowledgeEntityId(value.entityId); - return entityId === null ? null : { entityId, kind: "entity", v: 1 }; - } - if (value.kind === "model" && hasExactKeys(value, ["kind", "model", "receiptSha256", "v"])) { - const model = parseKnowledgeSchemaRefV1(value.model); - const receiptSha256 = parseSha256Hex(value.receiptSha256); - return model.ok && receiptSha256 !== null ? { kind: "model", model: model.value, receiptSha256, v: 1 } : null; - } - if (value.kind === "system" && hasExactKeys(value, ["authority", "kind", "receiptSha256", "v"])) { - const authority = parseKnowledgeSchemaRefV1(value.authority); - const receiptSha256 = parseSha256Hex(value.receiptSha256); - return authority.ok && receiptSha256 !== null ? { authority: authority.value, kind: "system", receiptSha256, v: 1 } : null; - } - return null; -} -function parseDigestArray(value, maximum = 2048) { - if (!Array.isArray(value) || value.length > maximum) - return null; - const digests = value.map(parseSha256Hex); - return digests.every((digest) => digest !== null) && orderedUnique(digests, String) ? digests : null; -} -var OH_KNOWLEDGE_ACTIVITY_KINDS_V1 = [ - "extraction", - "human-entry", - "human-review", - "import", - "model-proposal", - "normalization", - "publication", - "resolution", - "transformation" -]; -function parseActivityInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "actor", - "inputSha256s", - "kind", - "occurredAt", - "outputSha256s", - "policySha256", - "tool", - "v" - ]) || value.v !== 1) - return null; - const actor = parseKnowledgeAgentRefV1(value.actor); - const inputSha256s = parseDigestArray(value.inputSha256s); - const kind = OH_KNOWLEDGE_ACTIVITY_KINDS_V1.find((candidate) => candidate === value.kind); - const occurredAt = parseCanonicalInstantV1(value.occurredAt); - const outputSha256s = parseDigestArray(value.outputSha256s); - const policySha256 = parseSha256Hex(value.policySha256); - const tool = value.tool === null ? null : parseKnowledgeSchemaRefV1(value.tool); - const parsedTool = tool === null ? null : tool.ok ? tool.value : null; - return actor !== null && inputSha256s !== null && kind !== undefined && occurredAt !== null && outputSha256s !== null && policySha256 !== null && (value.tool === null || parsedTool !== null) ? { actor, inputSha256s, kind, occurredAt, outputSha256s, policySha256, tool: parsedTool, v: 1 } : null; -} -function createKnowledgeActivityV1(input) { - const parsed = parseActivityInput(input); - return parsed === null ? failure("activity") : success({ ...parsed, activitySha256: canonicalSha256(parsed) }); -} -function parseKnowledgeActivityV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "activitySha256")) - return failure("activity"); - const activitySha256 = parseSha256Hex(value.activitySha256); - const { activitySha256: _digest, ...input } = value; - const parsed = parseActivityInput(input); - return activitySha256 !== null && parsed !== null && canonicalSha256(parsed) === activitySha256 ? success({ ...parsed, activitySha256 }) : failure("activitySha256", "digest-mismatch"); -} -var OH_KNOWLEDGE_ASSERTION_STANCES_V1 = ["questions", "refutes", "reports", "supports", "undetermined"]; -var OH_KNOWLEDGE_ASSERTION_STATES_V1 = [ - "accepted-for-purpose", - "disputed", - "proposed", - "reviewed", - "superseded", - "withdrawn" -]; -function parseStringCodes(value, maximum) { - if (!Array.isArray(value) || value.length > maximum) - return null; - const codes = value.map((item) => safeCode(item)); - return codes.every((code) => code !== null) && orderedUnique(codes, String) ? codes : null; -} -function parseAssertionInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "acceptedPurposes", - "assertionId", - "assertor", - "confidence", - "contextSha256", - "provenanceActivitySha256", - "reviewActivitySha256", - "stance", - "state", - "statementSha256", - "v" - ]) || value.v !== 1) - return null; - const acceptedPurposes = parseStringCodes(value.acceptedPurposes, 32); - const assertionId = parseKnowledgeAssertionId(value.assertionId); - const assertor = parseKnowledgeAgentRefV1(value.assertor); - const confidence = value.confidence === null ? null : parseKnowledgeSchemaRefV1(value.confidence); - const parsedConfidence = confidence === null ? null : confidence.ok ? confidence.value : null; - const contextSha256 = value.contextSha256 === null ? null : parseSha256Hex(value.contextSha256); - const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); - const reviewActivitySha256 = value.reviewActivitySha256 === null ? null : parseSha256Hex(value.reviewActivitySha256); - const stance = OH_KNOWLEDGE_ASSERTION_STANCES_V1.find((candidate) => candidate === value.stance); - const state = OH_KNOWLEDGE_ASSERTION_STATES_V1.find((candidate) => candidate === value.state); - const statementSha256 = parseSha256Hex(value.statementSha256); - if (acceptedPurposes === null || assertionId === null || assertor === null || value.confidence !== null && parsedConfidence === null || value.contextSha256 !== null && contextSha256 === null || provenanceActivitySha256 === null || value.reviewActivitySha256 !== null && reviewActivitySha256 === null || stance === undefined || state === undefined || statementSha256 === null) - return null; - if (assertor.kind === "model" && (state !== "proposed" || acceptedPurposes.length !== 0 || reviewActivitySha256 !== null)) - return null; - if (state === "accepted-for-purpose" !== acceptedPurposes.length > 0 || state !== "proposed" && reviewActivitySha256 === null) - return null; - return { - acceptedPurposes, - assertionId, - assertor, - confidence: parsedConfidence, - contextSha256, - provenanceActivitySha256, - reviewActivitySha256, - stance, - state, - statementSha256, - v: 1 - }; -} -function createKnowledgeAssertionV1(input) { - const parsed = parseAssertionInput(input); - return parsed === null ? failure("assertion") : success({ ...parsed, assertionSha256: canonicalSha256(parsed) }); -} -function parseKnowledgeAssertionV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "assertionSha256")) - return failure("assertion"); - const assertionSha256 = parseSha256Hex(value.assertionSha256); - const { assertionSha256: _digest, ...input } = value; - const parsed = parseAssertionInput(input); - return assertionSha256 !== null && parsed !== null && canonicalSha256(parsed) === assertionSha256 ? success({ ...parsed, assertionSha256 }) : failure("assertionSha256", "digest-mismatch"); -} -var OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1 = [ - "background", - "contradicts", - "corroborates", - "direct-observation", - "method", - "quotation", - "registry-record", - "supports" -]; -function parseEvidenceInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "assertionSha256", - "bearing", - "disclosure", - "evidenceId", - "observationSha256", - "provenanceActivitySha256", - "selector", - "sourceEntityId", - "v" - ]) || value.v !== 1) - return null; - const assertionSha256 = parseSha256Hex(value.assertionSha256); - const bearing = OH_KNOWLEDGE_EVIDENCE_BEARINGS_V1.find((candidate) => candidate === value.bearing); - const evidenceId = parseKnowledgeEvidenceId(value.evidenceId); - const observationSha256 = value.observationSha256 === null ? null : parseSha256Hex(value.observationSha256); - const provenanceActivitySha256 = parseSha256Hex(value.provenanceActivitySha256); - const selector = value.selector === null ? null : boundedText(value.selector, 8192); - const sourceEntityId = value.sourceEntityId === null ? null : parseKnowledgeEntityId(value.sourceEntityId); - return assertionSha256 !== null && bearing !== undefined && (value.disclosure === "private" || value.disclosure === "public" || value.disclosure === "shared") && evidenceId !== null && (value.observationSha256 === null || observationSha256 !== null) && provenanceActivitySha256 !== null && (value.selector === null || selector !== null) && (value.sourceEntityId === null || sourceEntityId !== null) && (observationSha256 !== null || sourceEntityId !== null) ? { - assertionSha256, - bearing, - disclosure: value.disclosure, - evidenceId, - observationSha256, - provenanceActivitySha256, - selector, - sourceEntityId, - v: 1 - } : null; -} -function createKnowledgeEvidenceLinkV1(input) { - const parsed = parseEvidenceInput(input); - return parsed === null ? failure("evidence") : success({ ...parsed, evidenceSha256: canonicalSha256(parsed) }); -} -function parseKnowledgeEvidenceLinkV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "evidenceSha256")) - return failure("evidence"); - const evidenceSha256 = parseSha256Hex(value.evidenceSha256); - const { evidenceSha256: _digest, ...input } = value; - const parsed = parseEvidenceInput(input); - return evidenceSha256 !== null && parsed !== null && canonicalSha256(parsed) === evidenceSha256 ? success({ ...parsed, evidenceSha256 }) : failure("evidenceSha256", "digest-mismatch"); -} -function createKnowledgeInquiryV1(input) { - const answerForm = safeCode(input.answerForm); - const authorEntityId = parseKnowledgeEntityId(input.authorEntityId); - const contextSha256 = input.contextSha256 === null ? null : parseSha256Hex(input.contextSha256); - const createdAt = parseCanonicalInstantV1(input.createdAt); - const inquiryId = parseKnowledgeInquiryId(input.inquiryId); - const language = typeof input.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(input.language) ? input.language : null; - const parents = Array.isArray(input.parentInquiryIds) ? input.parentInquiryIds.map(parseKnowledgeInquiryId) : null; - const question = boundedText(input.question, 16384); - if (input.v !== 1 || answerForm === null || authorEntityId === null || input.contextSha256 !== null && contextSha256 === null || createdAt === null || inquiryId === null || language === null || parents === null || parents.some((item) => item === null) || !orderedUnique(parents, String) || !["private", "public", "shared"].includes(input.privacy) || question === null || !["abandoned", "open", "paused", "resolved"].includes(input.status)) - return failure("inquiry"); - const payload = { - answerForm, - authorEntityId, - contextSha256, - createdAt, - inquiryId, - language, - parentInquiryIds: parents, - privacy: input.privacy, - question, - status: input.status, - v: 1 - }; - return success({ ...payload, inquirySha256: canonicalSha256(payload) }); -} -function parseKnowledgeInquiryV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "inquirySha256")) - return failure("inquiry"); - const digest = parseSha256Hex(value.inquirySha256); - const { inquirySha256: _digest, ...input } = value; - const created = createKnowledgeInquiryV1(input); - return digest !== null && created.ok && created.value.inquirySha256 === digest ? success({ ...created.value, inquirySha256: digest }) : failure("inquirySha256", "digest-mismatch"); -} // src/schema.ts var OH_SCHEMA_FORMAT_VERSION_V1 = 1; -var OH_SCHEMA_KINDS_V1 = ["concept", "mapping", "predicate", "shape", "unit", "vocabulary"]; -function parseLocalizedTexts(value) { - if (!Array.isArray(value) || value.length === 0 || value.length > 128) - return null; - const output = []; - for (const item of value) { - if (!isPlainRecord(item) || !hasExactKeys(item, ["language", "text", "v"]) || item.v !== 1) - return null; - const language = typeof item.language === "string" && /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(item.language) ? item.language : null; - const text = boundedText(item.text, 16384); - if (language === null || text === null) - return null; - output.push({ language, text, v: 1 }); - } - return orderedUnique(output, canonicalJson) ? output : null; -} -function parseSchemaInput(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, [ - "body", - "code", - "compatibility", - "description", - "kind", - "labels", - "namespace", - "previousSchemaSha256", - "revision", - "v" - ]) || value.v !== 1 || !isPlainRecord(value.body)) - return null; - try { - canonicalJson(value.body); - } catch { - return null; - } - const code = safeCode(value.code); - const namespace = safeCode(value.namespace); - const kind = OH_SCHEMA_KINDS_V1.find((candidate) => candidate === value.kind); - const labels = parseLocalizedTexts(value.labels); - const description = parseLocalizedTexts(value.description); - const previousSchemaSha256 = value.previousSchemaSha256 === null ? null : parseSha256Hex(value.previousSchemaSha256); - const revision = Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : null; - const compatibility = value.compatibility === "additive" || value.compatibility === "breaking" ? value.compatibility : null; - return code !== null && namespace !== null && kind !== undefined && labels !== null && description !== null && (value.previousSchemaSha256 === null || previousSchemaSha256 !== null) && revision !== null && compatibility !== null && revision === 1 === (previousSchemaSha256 === null) && (revision !== 1 || compatibility === "additive") ? { - body: value.body, - code, - compatibility, - description, - kind, - labels, - namespace, - previousSchemaSha256, - revision, - v: 1 - } : null; -} -function createKnowledgeSchemaRevisionV1(input) { - const parsed = parseSchemaInput(input); - if (parsed === null) - throw new TypeError("Invalid schema revision input."); - return { ...parsed, schemaSha256: canonicalSha256(parsed) }; -} -function parseKnowledgeSchemaRevisionV1(value) { - if (!isPlainRecord(value) || !Object.hasOwn(value, "schemaSha256")) - return null; - const schemaSha256 = parseSha256Hex(value.schemaSha256); - const { schemaSha256: _digest, ...input } = value; - const parsed = parseSchemaInput(input); - return schemaSha256 !== null && parsed !== null && canonicalSha256(parsed) === schemaSha256 ? { ...parsed, schemaSha256 } : null; -} -function knowledgeSchemaRefV1(schema) { - return { - code: schema.code, - namespace: schema.namespace, - revision: schema.revision, - schemaSha256: schema.schemaSha256, - v: 1 - }; -} -function additiveBodyRetainsPrior(prior, next) { - return Object.entries(prior).every(([key, value]) => Object.hasOwn(next, key) && canonicalJson(next[key]) === canonicalJson(value)); -} -function verifyKnowledgeSchemaEvolutionV1(prior, next) { - if (parseKnowledgeSchemaRevisionV1(prior) === null || parseKnowledgeSchemaRevisionV1(next) === null) { - return { ok: false, reason: "invalid-schema" }; - } - if (prior.namespace !== next.namespace || prior.code !== next.code || prior.kind !== next.kind) { - return { ok: false, reason: "identity-changed" }; - } - if (next.revision !== prior.revision + 1 || next.previousSchemaSha256 !== prior.schemaSha256) { - return { ok: false, reason: "chain-broken" }; - } - if (next.compatibility === "additive" && !additiveBodyRetainsPrior(prior.body, next.body)) { - return { ok: false, reason: "false-additive-claim" }; - } - return { ok: true }; -} -function createKnowledgeVocabularyRevisionV1(input) { - const namespace = safeCode(input.namespace); - if (namespace === null || input.v !== 1 || !Number.isSafeInteger(input.revision) || input.revision < 1 || !Array.isArray(input.schemaRefs) || input.schemaRefs.length > 65536) { - throw new TypeError("Invalid vocabulary revision input."); - } - const refs = []; - for (const candidate of input.schemaRefs) { - const parsed = parseKnowledgeSchemaRefV1(candidate); - if (!parsed.ok || parsed.value.namespace !== namespace) - throw new TypeError("Invalid vocabulary schema reference."); - refs.push(parsed.value); - } - if (!orderedUnique(refs, canonicalJson)) - throw new TypeError("Vocabulary schema references must be ordered and unique."); - const payload = { namespace, revision: input.revision, schemaRefs: refs, v: 1 }; - return { ...payload, vocabularySha256: canonicalSha256(payload) }; -} -function parseKnowledgeVocabularyRevisionV1(value) { - if (!isPlainRecord(value) || !hasExactKeys(value, ["namespace", "revision", "schemaRefs", "v", "vocabularySha256"])) - return null; - const digest = parseSha256Hex(value.vocabularySha256); - try { - const created = createKnowledgeVocabularyRevisionV1({ - namespace: value.namespace, - revision: value.revision, - schemaRefs: value.schemaRefs, - v: value.v - }); - return digest !== null && created.vocabularySha256 === digest ? { ...created, vocabularySha256: digest } : null; - } catch { - return null; - } -} // src/contract.ts var manifestPayload = Object.freeze({ @@ -1024,20 +298,15 @@ var OH_CONTRACT_MANIFEST_V1 = Object.freeze({ ...manifestPayload, contractSha256: canonicalSha256(manifestPayload) }); -function parseOhContractManifestV1(value) { - try { - return canonicalJson(value) === canonicalJson(OH_CONTRACT_MANIFEST_V1) ? OH_CONTRACT_MANIFEST_V1 : null; - } catch { - return null; - } -} - class OhRecordCodecRegistry { #codecs = new Map; + #sealed = false; register(codec) { + if (this.#sealed) + throw new TypeError("The codec registry is sealed."); if (this.#codecs.has(codec.kind)) throw new TypeError(`A codec is already registered for ${codec.kind}.`); - this.#codecs.set(codec.kind, codec); + this.#codecs.set(codec.kind, Object.freeze({ kind: codec.kind, parse: codec.parse })); return this; } parse(kind, value) { @@ -1054,6 +323,27 @@ class OhRecordCodecRegistry { has(kind) { return this.#codecs.has(kind); } + parseRequired(kind, value) { + const codec = this.#codecs.get(kind); + if (codec === undefined) + return null; + try { + const parsed = codec.parse(value); + if (parsed === null) + return null; + canonicalJson(parsed); + return parsed; + } catch { + return null; + } + } + seal() { + this.#sealed = true; + return this; + } + get sealed() { + return this.#sealed; + } } // src/projection.ts @@ -1072,10 +362,13 @@ var OH_PROJECTION_LIMITS_V1 = Object.freeze({ queryMatches: 262144, queryResults: 65536, relations: 4096, + resultBytes: 16 * 1024 * 1024, rounds: 1024, rules: 1024, sourcesPerFact: 64, - variables: 256 + totalProofNodes: 65536, + variables: 256, + workUnits: 16777216 }); var recordFactExtractorPayloadV1 = { factPackId: "oh.record-facts", @@ -1514,9 +807,15 @@ function createOhProjectionIdentityV1(input) { if (snapshot === null || dataset === null || query === null || rulePack === null) { throw new TypeError("Invalid projection identity input."); } + const engine = safeCode(input.engine ?? OH_PROJECTION_INTERNAL_ENGINE_V1, 256); + if (engine === null) + throw new TypeError("Invalid projection engine identity."); + const evaluation = { ...resolveEvaluationOptions(input.options ?? {}), v: 1 }; const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, datasetSha256: dataset.datasetSha256, + engineSha256: canonicalSha256({ engine, v: 1 }), + evaluationSha256: canonicalSha256(evaluation), querySha256: query.querySha256, rulePackSha256: rulePack.rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, @@ -1529,6 +828,8 @@ function parseOhProjectionIdentityV1(value) { if (!isPlainRecord(value) || !hasExactKeys(value, [ "contractSha256", "datasetSha256", + "engineSha256", + "evaluationSha256", "projectionSha256", "querySha256", "rulePackSha256", @@ -1539,15 +840,19 @@ function parseOhProjectionIdentityV1(value) { return null; const contractSha256 = parseSha256Hex(value.contractSha256); const datasetSha256 = parseSha256Hex(value.datasetSha256); + const engineSha256 = parseSha256Hex(value.engineSha256); + const evaluationSha256 = parseSha256Hex(value.evaluationSha256); const projectionSha256 = parseSha256Hex(value.projectionSha256); const querySha256 = parseSha256Hex(value.querySha256); const rulePackSha256 = parseSha256Hex(value.rulePackSha256); const snapshotSha256 = parseSha256Hex(value.snapshotSha256); - if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) + if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null || engineSha256 === null || evaluationSha256 === null || projectionSha256 === null || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) return null; const payload = { contractSha256, datasetSha256, + engineSha256, + evaluationSha256, querySha256, rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, @@ -1568,6 +873,10 @@ function invalidationForOhProjectionV1(previous, next) { reasons.push("snapshot-changed"); if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) reasons.push("dataset-changed"); + if (parsedPrevious.engineSha256 !== parsedNext.engineSha256) + reasons.push("engine-changed"); + if (parsedPrevious.evaluationSha256 !== parsedNext.evaluationSha256) + reasons.push("evaluation-changed"); if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) reasons.push("rule-pack-changed"); if (parsedPrevious.querySha256 !== parsedNext.querySha256) @@ -1625,13 +934,19 @@ function unifyLiteral(literal, state, binding) { } return next; } -function matchBody(relations, body, maximumMatches) { +function consumeWorkUnit(budget) { + if (budget.units >= budget.maximum) + throw new RangeError("Projection exceeds its work-unit bound."); + budget.units += 1; +} +function matchBody(relations, body, maximumMatches, work) { let matches = [{ binding: new Map, premises: [] }]; for (const literal of body) { const next = []; const candidates = relationTuples(relations, literal.relation); for (const match of matches) { for (const candidate of candidates) { + consumeWorkUnit(work); const binding = unifyLiteral(literal, candidate, match.binding); if (binding === null) continue; @@ -1672,7 +987,7 @@ function materializeNaive(input) { while (true) { const candidates = new Map; for (const rule of input.rulePack.rules) { - for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches, input.work)) { const derivedTuple = instantiateHead(rule.head, match.binding); const relation = relations.get(rule.head.relation); const key = tupleKey(derivedTuple); @@ -1717,52 +1032,97 @@ function boundedOption(value, fallback, maximum, label) { return parsed; } function resolveEvaluationOptions(options) { - return { + const resolved = { maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), maximumProofNodes: boundedOption(options.maximumProofNodes, 1024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), - maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds") + maximumResultBytes: boundedOption(options.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, "maximumResultBytes"), + maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds"), + maximumTotalProofNodes: boundedOption(options.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, "maximumTotalProofNodes"), + maximumWorkUnits: boundedOption(options.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits, OH_PROJECTION_LIMITS_V1.workUnits, "maximumWorkUnits") }; -} -function proofForReference(relations, reference, budget, options, depth, visiting) { - if (budget.nodes >= options.maximumProofNodes) - return null; - if (budget.nodes === options.maximumProofNodes - 1) { - budget.nodes += 1; - return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; + if (resolved.maximumResultBytes < 64 * 1024) { + throw new RangeError("maximumResultBytes must be at least 65536."); } + return resolved; +} +function reserveResultBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function reserveProofNode(budget, options, envelope) { + if (budget.nodes >= options.maximumProofNodes || budget.result.nodes >= options.maximumTotalProofNodes || !reserveResultBytes(budget.result, envelope)) + return false; budget.nodes += 1; + budget.result.nodes += 1; + return true; +} +function proofForReference(relations, reference, budget, options, depth, visiting) { if (depth >= options.maximumProofDepth) { - return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { + kind: "truncated", + reason: "depth", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; } const identity = referenceKey(reference); if (visiting.has(identity)) { - return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { + kind: "truncated", + reason: "cycle", + relation: reference.relation, + tuple: reference.tuple, + v: 1 + }; + return reserveProofNode(budget, options, proof) ? proof : null; } const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); if (state === undefined) throw new Error("Projection proof references a tuple outside the materialized result."); if (state.witness.kind === "fact") { - return { + const proof = { kind: "fact", relation: reference.relation, sources: state.witness.sources, tuple: reference.tuple, v: 1 }; - } + return reserveProofNode(budget, options, proof) ? proof : null; + } + const envelope = { + kind: "derived", + premises: [], + premisesTruncated: false, + relation: reference.relation, + ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, + tuple: reference.tuple, + v: 1 + }; + if (!reserveProofNode(budget, options, envelope)) + return null; visiting.add(identity); try { const premises = []; + let premisesTruncated = false; for (const premise of state.witness.premises) { const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); - if (proof === null) + if (proof === null) { + premisesTruncated = true; break; + } premises.push(proof); } return { kind: "derived", premises, + premisesTruncated, relation: reference.relation, ruleId: state.witness.rule.ruleId, ruleSha256: state.witness.rule.ruleSha256, @@ -1773,33 +1133,334 @@ function proofForReference(relations, reference, budget, options, depth, visitin visiting.delete(identity); } } +function proofIsTruncated(proof) { + return proof.kind === "truncated" || proof.kind === "derived" && (proof.premisesTruncated || proof.premises.some(proofIsTruncated)); +} +function reserveProjectionParseBytes(budget, value) { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) + return false; + budget.bytes += bytes; + return true; +} +function parseProjectionFactSource(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["key", "recordSha256", "v"]) || value.v !== 1) { + return null; + } + const key = safeCode(value.key, 512); + const recordSha256 = parseSha256Hex(value.recordSha256); + return key === null || recordSha256 === null ? null : { key, recordSha256, v: 1 }; +} +function parseProjectionProofWithBudget(value, budget, depth) { + if (depth > budget.maximumDepth || budget.nodes >= budget.maximumNodes || !isPlainRecord(value) || value.v !== 1) + return null; + const relation = projectionName(value.relation); + const parsedTuple = tuple(value.tuple); + if (relation === null || parsedTuple === null) + return null; + if (value.kind === "fact") { + if (!hasExactKeys(value, ["kind", "relation", "sources", "tuple", "v"]) || !Array.isArray(value.sources) || value.sources.length < 1 || value.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) + return null; + const sources = value.sources.map(parseProjectionFactSource); + if (sources.some((source) => source === null)) + return null; + const parsedSources = sources; + if (!orderedUnique(parsedSources, (source) => source.key)) + return null; + const proof = { + kind: "fact", + relation, + sources: parsedSources, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind === "truncated") { + if (!hasExactKeys(value, ["kind", "reason", "relation", "tuple", "v"]) || value.reason !== "cycle" && value.reason !== "depth" && value.reason !== "nodes") + return null; + const reason = value.reason; + const proof = { + kind: "truncated", + reason, + relation, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, proof)) + return null; + budget.nodes += 1; + return proof; + } + if (value.kind !== "derived" || !hasExactKeys(value, [ + "kind", + "premises", + "premisesTruncated", + "relation", + "ruleId", + "ruleSha256", + "tuple", + "v" + ]) || !Array.isArray(value.premises) || value.premises.length > OH_PROJECTION_LIMITS_V1.literalsPerRule || typeof value.premisesTruncated !== "boolean") + return null; + const ruleId = projectionName(value.ruleId); + const ruleSha256 = parseSha256Hex(value.ruleSha256); + if (ruleId === null || ruleSha256 === null || !value.premisesTruncated && value.premises.length === 0 || value.premisesTruncated && value.premises.length === OH_PROJECTION_LIMITS_V1.literalsPerRule) { + return null; + } + const skeleton = { + kind: "derived", + premises: [], + premisesTruncated: value.premisesTruncated, + relation, + ruleId, + ruleSha256, + tuple: parsedTuple, + v: 1 + }; + if (!reserveProjectionParseBytes(budget, skeleton)) + return null; + budget.nodes += 1; + const premises = []; + for (const premise of value.premises) { + const parsed = parseProjectionProofWithBudget(premise, budget, depth + 1); + if (parsed === null) + return null; + premises.push(parsed); + } + return { ...skeleton, premises }; +} +function parseOhProjectionProofV1(value) { + try { + const budget = { + bytes: 0, + maximumBytes: OH_PROJECTION_LIMITS_V1.resultBytes, + maximumDepth: OH_PROJECTION_LIMITS_V1.proofDepth, + maximumNodes: OH_PROJECTION_LIMITS_V1.proofNodes, + nodes: 0 + }; + const proof = parseProjectionProofWithBudget(value, budget, 0); + return proof !== null && utf8ByteLength(canonicalJson(proof)) <= budget.maximumBytes ? proof : null; + } catch { + return null; + } +} +function parseProjectionEvaluation(value) { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "maximumDerivedTuples", + "maximumProofDepth", + "maximumProofNodes", + "maximumResultBytes", + "maximumRounds", + "maximumTotalProofNodes", + "maximumWorkUnits", + "v" + ]) || value.v !== 1) + return null; + const maximumDerivedTuples = positiveInteger(value.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples); + const maximumProofDepth = positiveInteger(value.maximumProofDepth, OH_PROJECTION_LIMITS_V1.proofDepth); + const maximumProofNodes = positiveInteger(value.maximumProofNodes, OH_PROJECTION_LIMITS_V1.proofNodes); + const maximumResultBytes = positiveInteger(value.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes); + const maximumRounds = positiveInteger(value.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds); + const maximumTotalProofNodes = positiveInteger(value.maximumTotalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes); + const maximumWorkUnits = positiveInteger(value.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits); + if (maximumDerivedTuples === null || maximumProofDepth === null || maximumProofNodes === null || maximumResultBytes === null || maximumResultBytes < 64 * 1024 || maximumRounds === null || maximumTotalProofNodes === null || maximumWorkUnits === null) + return null; + return { + maximumDerivedTuples, + maximumProofDepth, + maximumProofNodes, + maximumResultBytes, + maximumRounds, + maximumTotalProofNodes, + maximumWorkUnits, + v: 1 + }; +} +function parseProjectionResultRow(value, evaluation, resultBudget) { + if (!isPlainRecord(value) || !hasExactKeys(value, ["proofs", "proofsTruncated", "supportCount", "values", "v"]) || value.v !== 1 || !Array.isArray(value.proofs) || value.proofs.length > OH_PROJECTION_LIMITS_V1.queryLiterals || typeof value.proofsTruncated !== "boolean") + return null; + const values = tuple(value.values); + const supportCount = positiveInteger(value.supportCount, OH_PROJECTION_LIMITS_V1.queryMatches); + if (values === null || supportCount === null || !value.proofsTruncated && value.proofs.length === 0) + return null; + if (!reserveProjectionParseBytes(resultBudget, { + proofs: [], + proofsTruncated: value.proofsTruncated, + supportCount, + values, + v: 1 + })) + return null; + const before = resultBudget.nodes; + resultBudget.maximumNodes = Math.min(resultBudget.maximumNodes, before + evaluation.maximumProofNodes); + const proofs = []; + for (const proof of value.proofs) { + const parsed = parseProjectionProofWithBudget(proof, resultBudget, 0); + if (parsed === null) + return null; + proofs.push(parsed); + } + resultBudget.maximumNodes = evaluation.maximumTotalProofNodes; + const containsTruncation = proofs.some(proofIsTruncated); + if (!value.proofsTruncated && containsTruncation || value.proofsTruncated && proofs.length === OH_PROJECTION_LIMITS_V1.queryLiterals && !containsTruncation) + return null; + return { + nodes: resultBudget.nodes - before, + row: { proofs, proofsTruncated: value.proofsTruncated, supportCount, values, v: 1 } + }; +} +function parseOhProjectionResultV1(value, expectedProjectionSha256) { + try { + if (!isPlainRecord(value) || !hasExactKeys(value, [ + "authority", + "cache", + "engine", + "evaluation", + "identity", + "resultSha256", + "rows", + "stats", + "v" + ]) || value.v !== 1 || value.authority !== "derived" || !isPlainRecord(value.cache) || !hasExactKeys(value.cache, ["strategy", "v"]) || value.cache.strategy !== "full-rebuild" || value.cache.v !== 1 || !Array.isArray(value.rows) || value.rows.length > OH_PROJECTION_LIMITS_V1.queryResults || !isPlainRecord(value.stats) || !hasExactKeys(value.stats, [ + "baseFacts", + "derivedFacts", + "proofNodes", + "proofsTruncated", + "queryMatches", + "relations", + "rounds", + "truncated", + "truncationReasons", + "v", + "workUnits" + ]) || value.stats.v !== 1 || !Array.isArray(value.stats.truncationReasons) || typeof value.stats.proofsTruncated !== "boolean" || typeof value.stats.truncated !== "boolean") + return null; + const engine = safeCode(value.engine, 256); + const evaluation = parseProjectionEvaluation(value.evaluation); + const identity = parseOhProjectionIdentityV1(value.identity); + const resultSha256 = parseSha256Hex(value.resultSha256); + const expected = expectedProjectionSha256 === undefined ? undefined : parseSha256Hex(expectedProjectionSha256); + if (engine === null || evaluation === null || identity === null || resultSha256 === null || expectedProjectionSha256 !== undefined && expected === null || expected !== undefined && identity.projectionSha256 !== expected || identity.engineSha256 !== canonicalSha256({ engine, v: 1 }) || identity.evaluationSha256 !== canonicalSha256(evaluation)) + return null; + const baseFacts = nonnegativeInteger(value.stats.baseFacts); + const derivedFacts = nonnegativeInteger(value.stats.derivedFacts); + const proofNodes = nonnegativeInteger(value.stats.proofNodes); + const queryMatches = nonnegativeInteger(value.stats.queryMatches); + const relations = nonnegativeInteger(value.stats.relations); + const rounds = nonnegativeInteger(value.stats.rounds); + const workUnits = nonnegativeInteger(value.stats.workUnits); + if (baseFacts === null || baseFacts > OH_PROJECTION_LIMITS_V1.facts || derivedFacts === null || derivedFacts > evaluation.maximumDerivedTuples || proofNodes === null || proofNodes > evaluation.maximumTotalProofNodes || queryMatches === null || queryMatches > OH_PROJECTION_LIMITS_V1.queryMatches || relations === null || relations > OH_PROJECTION_LIMITS_V1.relations || rounds === null || rounds > evaluation.maximumRounds || workUnits === null || workUnits > evaluation.maximumWorkUnits || relations > baseFacts + derivedFacts || rounds > derivedFacts || rounds === 0 !== (derivedFacts === 0) || queryMatches > workUnits) + return null; + const truncationReasons = value.stats.truncationReasons; + if (truncationReasons.length > 2 || !orderedUnique(truncationReasons, (reason) => reason === "query-limit" ? "0" : reason === "result-bytes" ? "1" : "x") || truncationReasons.some((reason) => reason !== "query-limit" && reason !== "result-bytes") || value.stats.truncated !== truncationReasons.length > 0) + return null; + const budget = { + bytes: 0, + maximumBytes: evaluation.maximumResultBytes, + maximumDepth: evaluation.maximumProofDepth, + maximumNodes: evaluation.maximumTotalProofNodes, + nodes: 0 + }; + const rows = []; + let supportCount = 0; + for (const row of value.rows) { + const parsed = parseProjectionResultRow(row, evaluation, budget); + if (parsed === null) + return null; + rows.push(parsed.row); + supportCount += parsed.row.supportCount; + if (supportCount > queryMatches) + return null; + } + if (!orderedUnique(rows, (row) => canonicalJson(row.values)) || budget.nodes !== proofNodes || value.stats.proofsTruncated !== rows.some((row) => row.proofsTruncated) || (value.stats.truncated ? supportCount >= queryMatches : supportCount !== queryMatches)) + return null; + const reasons = truncationReasons; + const payload = { + authority: "derived", + cache: { strategy: "full-rebuild", v: 1 }, + engine, + evaluation, + identity, + rows, + stats: { + baseFacts, + derivedFacts, + proofNodes, + proofsTruncated: value.stats.proofsTruncated, + queryMatches, + relations, + rounds, + truncated: value.stats.truncated, + truncationReasons: reasons, + v: 1, + workUnits + }, + v: 1 + }; + const serialized = canonicalJson(payload); + return utf8ByteLength(serialized) <= evaluation.maximumResultBytes && sha256Hex(serialized) === resultSha256 ? { ...payload, resultSha256 } : null; + } catch { + return null; + } +} function buildProjectionResult(input) { - const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches); + const matches = matchBody(input.materialized.relations, input.query.where, OH_PROJECTION_LIMITS_V1.queryMatches, input.work); const byValues = new Map; for (const match of matches) { const values = input.query.find.map((name) => match.binding.get(name)); const key = tupleKey(values); const existing = byValues.get(key); - if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) - byValues.set(key, match); + if (existing === undefined) + byValues.set(key, { match, supportCount: 1 }); + else + byValues.set(key, { match: compareCanonical(match.premises, existing.match.premises) < 0 ? match : existing.match, supportCount: existing.supportCount + 1 }); } const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); - const truncated = ordered.length > input.query.limit; - const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const resultBudget = { + bytes: 0, + maximumBytes: input.options.maximumResultBytes - 64 * 1024, + nodes: 0 + }; + const rows = []; + let resultBytesTruncated = false; + for (const [key, support] of ordered.slice(0, input.query.limit)) { const values = JSON.parse(key); - const budget = { nodes: 0 }; + if (!reserveResultBytes(resultBudget, { + proofs: [], + proofsTruncated: false, + supportCount: support.supportCount, + values, + v: 1 + })) { + resultBytesTruncated = true; + break; + } + const budget = { nodes: 0, result: resultBudget }; const proofs = []; - for (const premise of match.premises) { + for (const premise of support.match.premises) { const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set); if (proof === null) break; proofs.push(proof); } - return { proofs, values, v: 1 }; - }); + const proofsTruncated = proofs.length !== support.match.premises.length || proofs.some(proofIsTruncated); + rows.push({ proofs, proofsTruncated, supportCount: support.supportCount, values, v: 1 }); + } + const queryLimitTruncated = ordered.length > input.query.limit; + const truncationReasons = [ + ...queryLimitTruncated ? ["query-limit"] : [], + ...resultBytesTruncated ? ["result-bytes"] : [] + ]; + const truncated = truncationReasons.length > 0; const identity = createOhProjectionIdentityV1({ dataset: input.dataset, query: input.query, + engine: input.engine, + options: input.options, rulePack: input.rulePack, snapshot: input.snapshot }); @@ -1813,15 +1474,23 @@ function buildProjectionResult(input) { stats: { baseFacts: input.materialized.baseFacts, derivedFacts: input.materialized.derivedFacts, + proofNodes: resultBudget.nodes, + proofsTruncated: rows.some((row) => row.proofsTruncated), queryMatches: matches.length, relations: input.materialized.relations.size, rounds: input.materialized.rounds, truncated, - v: 1 + truncationReasons, + v: 1, + workUnits: input.work.units }, v: 1 }; - return { ...payload, resultSha256: canonicalSha256(payload) }; + const serialized = canonicalJson(payload); + if (utf8ByteLength(serialized) > input.options.maximumResultBytes) { + throw new RangeError("Projection result exceeds its canonical byte bound."); + } + return { ...payload, resultSha256: sha256Hex(serialized) }; } function evaluateOhProjectionV1(input) { const snapshot = parseOhProjectionSnapshotV1(input.snapshot); @@ -1833,11 +1502,13 @@ function evaluateOhProjectionV1(input) { } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; const materialized = materializeNaive({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, - rulePack + rulePack, + work }); return buildProjectionResult({ dataset, @@ -1846,7 +1517,8 @@ function evaluateOhProjectionV1(input) { options, query, rulePack, - snapshot + snapshot, + work }); } function evaluateOhProjectionWithMaterializerV1(input) { @@ -1860,17 +1532,19 @@ function evaluateOhProjectionWithMaterializerV1(input) { } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); - const external = input.materialize({ + const work = { maximum: options.maximumWorkUnits, units: 0 }; + const witnessMaterialization = materializeNaive({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, - query, - rulePack + rulePack, + work }); - const witnessMaterialization = materializeNaive({ + const external = input.materialize({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, + query, rulePack }); const externalCanonical = new Map; @@ -1904,17 +1578,31 @@ function evaluateOhProjectionWithMaterializerV1(input) { options, query, rulePack, - snapshot + snapshot, + work }); } function createOhProjectionRecordFactsV1(records, options = {}) { if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) throw new RangeError("Too many records for projection facts."); - const facts = []; - for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { + const parsedRecords = [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0).map((candidate) => { const record = parseKnowledgeGraphRecordV1(candidate); if (record === null) throw new TypeError("Invalid graph record for projection facts."); + return record; + }); + let projectedFactCount = 0; + for (const record of parsedRecords) { + if (options.includeRecords !== false) + projectedFactCount += 1; + if (options.includeDependencies !== false) + projectedFactCount += record.dependencies.length; + if (projectedFactCount > OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("Structural projection exceeds its fact bound."); + } + } + const facts = []; + for (const record of parsedRecords) { const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 }]; if (options.includeRecords !== false) { facts.push(createOhProjectionFactV1({ @@ -1987,10 +1675,17 @@ function assertConservativeOutputBound(input) { const heads = new Map; for (const item of input.rulePack.rules) heads.set(item.head.relation, item.head.terms.length); + const baseCounts = new Map; + for (const fact of input.dataset.facts) { + if (heads.has(fact.relation)) + baseCounts.set(fact.relation, (baseCounts.get(fact.relation) ?? 0) + 1); + } let possible = 0n; - const maximum = BigInt(input.maximumDerivedTuples + input.dataset.facts.length); - for (const arity of heads.values()) { - possible += domainSize ** BigInt(arity); + const maximum = BigInt(input.maximumDerivedTuples); + for (const [relation, arity] of heads) { + const relationSpace = domainSize ** BigInt(arity); + const existing = BigInt(baseCounts.get(relation) ?? 0); + possible += relationSpace > existing ? relationSpace - existing : 0n; if (possible > maximum) { throw new RangeError("The Suss equivalence adapter cannot prove the requested derived-tuple bound before evaluation; use the bounded Oh evaluator."); } diff --git a/dist/projection.d.ts b/dist/projection.d.ts index 82e05cc..62b04a0 100644 --- a/dist/projection.d.ts +++ b/dist/projection.d.ts @@ -15,10 +15,13 @@ export declare const OH_PROJECTION_LIMITS_V1: Readonly<{ queryMatches: 262144; queryResults: 65536; relations: 4096; + resultBytes: number; rounds: 1024; rules: 1024; sourcesPerFact: 64; + totalProofNodes: 65536; variables: 256; + workUnits: 16777216; }>; export type OhProjectionAtomV1 = JsonPrimitive; export type OhProjectionSnapshotV1 = Readonly<{ @@ -105,6 +108,8 @@ export type OhProjectionQueryV1 = Readonly<{ export type OhProjectionIdentityV1 = Readonly<{ contractSha256: Sha256Hex; datasetSha256: Sha256Hex; + engineSha256: Sha256Hex; + evaluationSha256: Sha256Hex; projectionSha256: Sha256Hex; querySha256: Sha256Hex; rulePackSha256: Sha256Hex; @@ -121,6 +126,7 @@ export type OhProjectionProofV1 = Readonly<{ }> | Readonly<{ kind: "derived"; premises: readonly OhProjectionProofV1[]; + premisesTruncated: boolean; relation: string; ruleId: string; ruleSha256: Sha256Hex; @@ -135,6 +141,8 @@ export type OhProjectionProofV1 = Readonly<{ }>; export type OhProjectionResultRowV1 = Readonly<{ proofs: readonly OhProjectionProofV1[]; + proofsTruncated: boolean; + supportCount: number; values: readonly OhProjectionAtomV1[]; v: 1; }>; @@ -149,7 +157,10 @@ export type OhProjectionResultV1 = Readonly<{ maximumDerivedTuples: number; maximumProofDepth: number; maximumProofNodes: number; + maximumResultBytes: number; maximumRounds: number; + maximumTotalProofNodes: number; + maximumWorkUnits: number; v: 1; }>; identity: OhProjectionIdentityV1; @@ -158,10 +169,14 @@ export type OhProjectionResultV1 = Readonly<{ stats: Readonly<{ baseFacts: number; derivedFacts: number; + proofNodes: number; queryMatches: number; relations: number; rounds: number; + proofsTruncated: boolean; truncated: boolean; + truncationReasons: readonly ("query-limit" | "result-bytes")[]; + workUnits: number; v: 1; }>; v: 1; @@ -170,9 +185,12 @@ export type OhProjectionEvaluationOptionsV1 = Readonly<{ maximumDerivedTuples?: number; maximumProofDepth?: number; maximumProofNodes?: number; + maximumResultBytes?: number; maximumRounds?: number; + maximumTotalProofNodes?: number; + maximumWorkUnits?: number; }>; -export type OhProjectionInvalidationReasonV1 = "dataset-changed" | "query-changed" | "rule-pack-changed" | "snapshot-changed"; +export type OhProjectionInvalidationReasonV1 = "dataset-changed" | "engine-changed" | "evaluation-changed" | "query-changed" | "rule-pack-changed" | "snapshot-changed"; export type OhProjectionInvalidationV1 = Readonly<{ kind: "reusable"; v: 1; @@ -237,12 +255,27 @@ export declare function createOhProjectionQueryV1(input: Readonly<{ export declare function parseOhProjectionQueryV1(value: unknown): OhProjectionQueryV1 | null; export declare function createOhProjectionIdentityV1(input: Readonly<{ dataset: OhProjectionDatasetV1; + engine?: string; + options?: OhProjectionEvaluationOptionsV1; query: OhProjectionQueryV1; rulePack: OhProjectionRulePackV1; snapshot: OhProjectionSnapshotV1; }>): OhProjectionIdentityV1; export declare function parseOhProjectionIdentityV1(value: unknown): OhProjectionIdentityV1 | null; export declare function invalidationForOhProjectionV1(previous: OhProjectionIdentityV1, next: OhProjectionIdentityV1): OhProjectionInvalidationV1; +/** + * Parses one untrusted proof tree under the public hard depth, node, and byte + * ceilings. Cached result envelopes should normally be parsed as a whole with + * `parseOhProjectionResultV1`, which also applies their smaller declared limits. + */ +export declare function parseOhProjectionProofV1(value: unknown): OhProjectionProofV1 | null; +/** + * Parses a cached projection result as untrusted data. It verifies exact keys, + * all aggregate and declared bounds, proof truncation markers, canonical row + * order, engine/evaluation identity links, and `resultSha256`. Pass the + * projection digest requested from a cache to reject identity substitution. + */ +export declare function parseOhProjectionResultV1(value: unknown, expectedProjectionSha256?: Sha256Hex): OhProjectionResultV1 | null; export declare function evaluateOhProjectionV1(input: Readonly<{ dataset: OhProjectionDatasetV1; options?: OhProjectionEvaluationOptionsV1; diff --git a/dist/projection.d.ts.map b/dist/projection.d.ts.map index 9c3469c..eebcc9e 100644 --- a/dist/projection.d.ts.map +++ b/dist/projection.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/sdk.js b/dist/sdk.js index d236237..a351bca 100644 --- a/dist/sdk.js +++ b/dist/sdk.js @@ -1603,12 +1603,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -1732,9 +1734,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -1742,6 +1745,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -1815,6 +1821,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -2165,6 +2185,51 @@ function applyOhSqliteMigrations(database) { // src/sqlite/store.ts var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); +var OPERATION_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +var BINDING_COLUMNS = `space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json`; +var PURGE_COLUMNS = `space_id, binding_sha256, prior_operation_sha256, prior_sequence, + purged_at, receipt_sha256, receipt_json`; +function parseStoredOperationRow(row, expected = {}) { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json || row.operation_sha256 !== operation.operationSha256 || row.space_id !== operation.spaceId || row.sequence !== operation.sequence || row.operation_id !== operation.operationId || row.parent_operation_sha256 !== operation.parentOperationSha256 || row.graph_revision_sha256 !== operation.graphRevisionSha256 || row.records_sha256 !== operation.recordsSha256 || row.instant !== operation.instant || expected.spaceId !== undefined && operation.spaceId !== expected.spaceId || expected.operationId !== undefined && operation.operationId !== expected.operationId || expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256) { + throw new OhIntegrityError("Stored operation columns do not match their canonical envelope."); + } + return operation; +} +function parseStoredBindingRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json || binding.spaceId !== expectedSpaceId || row.space_id !== binding.spaceId || row.realm_id !== binding.realmId || row.profile_id !== binding.profile.profileId || row.profile_kind !== binding.profile.profileKind || row.profile_sha256 !== binding.profile.profileSha256 || row.binding_sha256 !== binding.bindingSha256) { + throw new OhIntegrityError("Stored binding columns do not match their canonical envelope."); + } + return binding; +} +function parseStoredPurgeRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== row.receipt_json || receipt.spaceId !== expectedSpaceId || row.space_id !== receipt.spaceId || row.binding_sha256 !== receipt.bindingSha256 || row.prior_operation_sha256 !== receipt.priorHead.operationSha256 || row.prior_sequence !== receipt.priorHead.sequence || row.purged_at !== receipt.purgedAt || row.receipt_sha256 !== receipt.receiptSha256) { + throw new OhIntegrityError("Stored purge columns do not match their canonical receipt."); + } + return receipt; +} function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -2269,26 +2334,18 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); - const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); - if (purged !== null) { - let value; - try { - value = JSON.parse(purged.receipt_json); - } catch { - throw new OhIntegrityError("A purge receipt is not JSON."); - } - const receipt = parseOhSpacePurgeReceiptV1(value); - if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { - throw new OhIntegrityError("A stored purge receipt is invalid."); + return withImmediateTransaction(this.database, () => { + const purged = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (purged !== null) { + throw new OhPurgedSpaceError(parseStoredPurgeRow(purged, this.spaceId)); } - throw new OhPurgedSpaceError(receipt); - } - const now = canonicalNow(); - this.database.query(`INSERT INTO oh_spaces( - space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, - records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); - return this.head(); + const now = canonicalNow(); + this.database.query(`INSERT INTO oh_spaces( + space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, + records_sha256, sequence, created_at, updated_at + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); + return this.head(); + }); } bind(bindingValue) { this.#assertOpen(); @@ -2301,28 +2358,21 @@ class OhSqliteStore { space_id, realm_id, profile_id, profile_kind, profile_sha256, binding_sha256, binding_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== bindingJson) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) + throw new OhIntegrityError("The persisted store binding disappeared."); + const persisted = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persisted) !== bindingJson) { throw new OhProfileError("The space is already bound to a different realm or profile."); } return binding; } binding() { this.#assertOpen(); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); if (row === null) return null; - let value; - try { - value = JSON.parse(row.binding_json); - } catch { - throw new OhIntegrityError("A store binding is not JSON."); - } - const binding = parseOhStoreBindingV1(value); - if (binding === null || canonicalJson(binding) !== row.binding_json) { - throw new OhIntegrityError("A stored binding is invalid."); - } - return binding; + return parseStoredBindingRow(row, this.spaceId); } head() { this.#assertOpen(); @@ -2384,6 +2434,48 @@ class OhSqliteStore { }); return { graphRevisionSha256, records, recordsSha256 }; } + #assertCurrentHeadAuthority(head) { + const summary = this.database.query(`SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_operations WHERE space_id = ?`).get(this.spaceId); + if (summary === null || summary.count !== head.sequence || head.sequence === 0 && (summary.minimum !== null || summary.maximum !== null) || head.sequence > 0 && (summary.minimum !== 1 || summary.maximum !== head.sequence)) { + throw new OhIntegrityError("The operation history does not exactly cover the current space head."); + } + if (head.sequence === 0) + return; + if (head.operationSha256 === null) + throw new OhIntegrityError("A nonempty space head has no operation digest."); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, head.sequence); + if (row === null) + throw new OhIntegrityError("The current space head operation is missing."); + const operation = parseStoredOperationRow(row, { + spaceId: this.spaceId, + operationSha256: head.operationSha256 + }); + if (operation.sequence !== head.sequence || operation.graphRevisionSha256 !== head.graphRevisionSha256 || operation.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The current space head differs from its canonical operation."); + } + } + #assertOperationReachable(operation, head) { + if (operation.sequence < 1 || operation.sequence > head.sequence) { + throw new OhIntegrityError("A stored idempotent operation is not reachable from the current head."); + } + const rows = this.database.query(`SELECT operation_sha256, parent_operation_sha256, sequence + FROM oh_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, operation.sequence, head.sequence); + if (rows.length !== head.sequence - operation.sequence + 1) { + throw new OhIntegrityError("A stored idempotent operation has an incomplete path to the current head."); + } + let priorSha256 = operation.parentOperationSha256; + for (let index = 0;index < rows.length; index += 1) { + const row = rows[index]; + if (row === undefined || row.sequence !== operation.sequence + index || row.parent_operation_sha256 !== priorSha256 || index === 0 && row.operation_sha256 !== operation.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation is not on the current authority chain."); + } + priorSha256 = row.operation_sha256; + } + if (priorSha256 !== head.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation does not reach the current head digest."); + } + } #persist(operation) { this.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, @@ -2445,17 +2537,17 @@ class OhSqliteStore { if (changes.length === 0 || changes.length > 8192) throw new TypeError("A commit needs 1 through 8192 changes."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND operation_id = ?").get(this.spaceId, operationId); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND operation_id = ?`).get(this.spaceId, operationId); if (duplicate !== null) { - const existing = parseOhOperationV1(JSON.parse(duplicate.operation_json)); - if (existing === null) - throw new OhIntegrityError("The stored idempotent operation is invalid."); + const existing = parseStoredOperationRow(duplicate, { operationId, spaceId: this.spaceId }); + this.#assertOperationReachable(existing, head); if (existing.actorId !== actorId || canonicalJson(existing.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return existing; } - const head = this.head(); if (head.generation !== input.expectedHead.generation || head.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current space head."); } @@ -2484,14 +2576,20 @@ class OhSqliteStore { if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE operation_sha256 = ?").get(operation.operationSha256); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE operation_sha256 = ?`).get(operation.operationSha256); if (duplicate !== null) { - if (canonicalJson(JSON.parse(duplicate.operation_json)) !== canonicalJson(operation)) { + const existing = parseStoredOperationRow(duplicate, { + operationSha256: operation.operationSha256, + spaceId: this.spaceId + }); + this.#assertOperationReachable(existing, head); + if (canonicalJson(existing) !== canonicalJson(operation)) { throw new OhIntegrityError("An operation digest is bound to different bytes."); } return { imported: false, operation }; } - const head = this.head(); if (operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { throw new OhConflictError("The imported operation does not extend the local head."); } @@ -2509,13 +2607,9 @@ class OhSqliteStore { if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(this.spaceId, afterSequence, boundedLimit); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null || canonicalJson(operation) !== row.operation_json) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`).all(this.spaceId, afterSequence, boundedLimit); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } #headAt(reference) { const parsed = parseOhHeadRefV1(reference); @@ -2523,20 +2617,11 @@ class OhSqliteStore { throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); - const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, parsed.sequence); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseStoredOperationRow(row, { spaceId: this.spaceId }); + if (operation.spaceId !== this.spaceId || operation.sequence !== parsed.sequence || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { @@ -2559,21 +2644,9 @@ class OhSqliteStore { const target = options.head === undefined ? current : this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); - const operations = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const operation = parseOhOperationV1(value); - if (operation === null) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, target.sequence); + const operations = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { throw new OhIntegrityError("Operation replay does not reproduce the requested head."); @@ -2594,34 +2667,23 @@ class OhSqliteStore { if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); } - const rows = this.database.query(`SELECT operation_json FROM oh_operations + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); - const parsed = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - return operation; - }); + const parsed = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); + if (parsed.length > limit + 1) + throw new OhIntegrityError("The change feed exceeded its requested page bound."); const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); - const first = operations[0]; - if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { - throw new OhIntegrityError("The change feed does not extend its cursor."); - } - for (let index = 1;index < operations.length; index += 1) { - const prior = operations[index - 1]; - const operation = operations[index]; + let prior = fromHead; + for (const operation of parsed) { if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The change feed contains a gap or fork."); } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + if (!hasMore && (prior.sequence !== through.sequence || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The change feed does not reach its pinned through head."); } const last = operations.at(-1); const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; @@ -2697,13 +2759,9 @@ class OhSqliteStore { } log(limit = 50) { this.#assertOpen(); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence DESC LIMIT ?").all(this.spaceId, normalizeLimit(limit)); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null) - throw new OhIntegrityError("The stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? ORDER BY sequence DESC LIMIT ?`).all(this.spaceId, normalizeLimit(limit)); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } searchKeyword(query, limit = 20) { this.#assertOpen(); @@ -2759,20 +2817,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const parsed = parseOhOperationV1(value); - if (parsed === null) - throw new OhIntegrityError("A stored operation is invalid."); - return parsed; - }); + const operations = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? ORDER BY sequence`).all(this.spaceId).map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); @@ -2780,6 +2825,7 @@ class OhSqliteStore { #verifyOperations(operations) { const records = new Map; const materializedBy = new Map; + const operationIds = new Set; let head = { generation: 0, graphRevisionSha256: null, @@ -2789,8 +2835,9 @@ class OhSqliteStore { v: 1 }; for (const operation of operations) { - if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) + if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) throw new OhIntegrityError("Operation replay chain is broken."); + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") { records.set(change.record.key, change.record); @@ -2874,8 +2921,12 @@ class OhSqliteStore { throw new OhProfileError("Whole-space purge requires a bound working profile."); } return withImmediateTransaction(this.database, () => { - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== canonicalJson(binding)) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const persistedBinding = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persistedBinding) !== canonicalJson(binding)) { throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); } const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); @@ -2893,6 +2944,33 @@ class OhSqliteStore { this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + const directTables = [ + "oh_spaces", + "oh_space_bindings", + "oh_operations", + "oh_records", + "oh_dependencies", + "oh_search_documents", + "oh_sync_outbox", + "oh_sync_state" + ]; + for (const table of directTables) { + const count = this.database.query(`SELECT count(*) AS count FROM ${table} WHERE space_id = ?`).get(this.spaceId)?.count; + if (count !== 0) + throw new OhIntegrityError(`Space purge left rows in ${table}.`); + } + const operationRecords = this.database.query(`SELECT count(*) AS count + FROM oh_operation_records AS materialized JOIN oh_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ?`).get(this.spaceId)?.count; + const searchRows = this.database.query("SELECT count(*) AS count FROM oh_search_fts WHERE space_id = ?").get(this.spaceId)?.count; + if (operationRecords !== 0 || searchRows !== 0) { + throw new OhIntegrityError("Space purge left derived private payload rows."); + } + const receiptRow = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (receiptRow === null || canonicalJson(parseStoredPurgeRow(receiptRow, this.spaceId)) !== canonicalJson(receipt)) { + throw new OhIntegrityError("The stored purge receipt differs from the requested purge."); + } return receipt; }); } diff --git a/dist/sqlite/index.js b/dist/sqlite/index.js index e77f858..e226ce4 100644 --- a/dist/sqlite/index.js +++ b/dist/sqlite/index.js @@ -1613,12 +1613,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -1742,9 +1744,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -1752,6 +1755,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -1825,6 +1831,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -1931,6 +1951,51 @@ class OhSemanticBundleIngressV1 { import { mkdirSync } from "fs"; import { dirname } from "path"; var EMPTY_RECORDS_SHA2562 = canonicalSha256([]); +var OPERATION_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +var BINDING_COLUMNS = `space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json`; +var PURGE_COLUMNS = `space_id, binding_sha256, prior_operation_sha256, prior_sequence, + purged_at, receipt_sha256, receipt_json`; +function parseStoredOperationRow(row, expected = {}) { + let value; + try { + value = JSON.parse(row.operation_json); + } catch { + throw new OhIntegrityError("A stored operation is not JSON."); + } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json || row.operation_sha256 !== operation.operationSha256 || row.space_id !== operation.spaceId || row.sequence !== operation.sequence || row.operation_id !== operation.operationId || row.parent_operation_sha256 !== operation.parentOperationSha256 || row.graph_revision_sha256 !== operation.graphRevisionSha256 || row.records_sha256 !== operation.recordsSha256 || row.instant !== operation.instant || expected.spaceId !== undefined && operation.spaceId !== expected.spaceId || expected.operationId !== undefined && operation.operationId !== expected.operationId || expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256) { + throw new OhIntegrityError("Stored operation columns do not match their canonical envelope."); + } + return operation; +} +function parseStoredBindingRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.binding_json); + } catch { + throw new OhIntegrityError("A store binding is not JSON."); + } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json || binding.spaceId !== expectedSpaceId || row.space_id !== binding.spaceId || row.realm_id !== binding.realmId || row.profile_id !== binding.profile.profileId || row.profile_kind !== binding.profile.profileKind || row.profile_sha256 !== binding.profile.profileSha256 || row.binding_sha256 !== binding.bindingSha256) { + throw new OhIntegrityError("Stored binding columns do not match their canonical envelope."); + } + return binding; +} +function parseStoredPurgeRow(row, expectedSpaceId) { + let value; + try { + value = JSON.parse(row.receipt_json); + } catch { + throw new OhIntegrityError("A purge receipt is not JSON."); + } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== row.receipt_json || receipt.spaceId !== expectedSpaceId || row.space_id !== receipt.spaceId || row.binding_sha256 !== receipt.bindingSha256 || row.prior_operation_sha256 !== receipt.priorHead.operationSha256 || row.prior_sequence !== receipt.priorHead.sequence || row.purged_at !== receipt.purgedAt || row.receipt_sha256 !== receipt.receiptSha256) { + throw new OhIntegrityError("Stored purge columns do not match their canonical receipt."); + } + return receipt; +} function parseHead(row) { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256); @@ -2035,26 +2100,18 @@ class OhSqliteStore { } ensureSpace() { this.#assertOpen(); - const purged = this.database.query("SELECT receipt_json FROM oh_space_purges WHERE space_id = ?").get(this.spaceId); - if (purged !== null) { - let value; - try { - value = JSON.parse(purged.receipt_json); - } catch { - throw new OhIntegrityError("A purge receipt is not JSON."); - } - const receipt = parseOhSpacePurgeReceiptV1(value); - if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { - throw new OhIntegrityError("A stored purge receipt is invalid."); + return withImmediateTransaction(this.database, () => { + const purged = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (purged !== null) { + throw new OhPurgedSpaceError(parseStoredPurgeRow(purged, this.spaceId)); } - throw new OhPurgedSpaceError(receipt); - } - const now = canonicalNow(); - this.database.query(`INSERT INTO oh_spaces( - space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, - records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); - return this.head(); + const now = canonicalNow(); + this.database.query(`INSERT INTO oh_spaces( + space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, + records_sha256, sequence, created_at, updated_at + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA2562, now, now); + return this.head(); + }); } bind(bindingValue) { this.#assertOpen(); @@ -2067,28 +2124,21 @@ class OhSqliteStore { space_id, realm_id, profile_id, profile_kind, profile_sha256, binding_sha256, binding_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow()); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== bindingJson) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) + throw new OhIntegrityError("The persisted store binding disappeared."); + const persisted = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persisted) !== bindingJson) { throw new OhProfileError("The space is already bound to a different realm or profile."); } return binding; } binding() { this.#assertOpen(); - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); if (row === null) return null; - let value; - try { - value = JSON.parse(row.binding_json); - } catch { - throw new OhIntegrityError("A store binding is not JSON."); - } - const binding = parseOhStoreBindingV1(value); - if (binding === null || canonicalJson(binding) !== row.binding_json) { - throw new OhIntegrityError("A stored binding is invalid."); - } - return binding; + return parseStoredBindingRow(row, this.spaceId); } head() { this.#assertOpen(); @@ -2150,6 +2200,48 @@ class OhSqliteStore { }); return { graphRevisionSha256, records, recordsSha256 }; } + #assertCurrentHeadAuthority(head) { + const summary = this.database.query(`SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_operations WHERE space_id = ?`).get(this.spaceId); + if (summary === null || summary.count !== head.sequence || head.sequence === 0 && (summary.minimum !== null || summary.maximum !== null) || head.sequence > 0 && (summary.minimum !== 1 || summary.maximum !== head.sequence)) { + throw new OhIntegrityError("The operation history does not exactly cover the current space head."); + } + if (head.sequence === 0) + return; + if (head.operationSha256 === null) + throw new OhIntegrityError("A nonempty space head has no operation digest."); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, head.sequence); + if (row === null) + throw new OhIntegrityError("The current space head operation is missing."); + const operation = parseStoredOperationRow(row, { + spaceId: this.spaceId, + operationSha256: head.operationSha256 + }); + if (operation.sequence !== head.sequence || operation.graphRevisionSha256 !== head.graphRevisionSha256 || operation.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The current space head differs from its canonical operation."); + } + } + #assertOperationReachable(operation, head) { + if (operation.sequence < 1 || operation.sequence > head.sequence) { + throw new OhIntegrityError("A stored idempotent operation is not reachable from the current head."); + } + const rows = this.database.query(`SELECT operation_sha256, parent_operation_sha256, sequence + FROM oh_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, operation.sequence, head.sequence); + if (rows.length !== head.sequence - operation.sequence + 1) { + throw new OhIntegrityError("A stored idempotent operation has an incomplete path to the current head."); + } + let priorSha256 = operation.parentOperationSha256; + for (let index = 0;index < rows.length; index += 1) { + const row = rows[index]; + if (row === undefined || row.sequence !== operation.sequence + index || row.parent_operation_sha256 !== priorSha256 || index === 0 && row.operation_sha256 !== operation.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation is not on the current authority chain."); + } + priorSha256 = row.operation_sha256; + } + if (priorSha256 !== head.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation does not reach the current head digest."); + } + } #persist(operation) { this.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, @@ -2211,17 +2303,17 @@ class OhSqliteStore { if (changes.length === 0 || changes.length > 8192) throw new TypeError("A commit needs 1 through 8192 changes."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND operation_id = ?").get(this.spaceId, operationId); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND operation_id = ?`).get(this.spaceId, operationId); if (duplicate !== null) { - const existing = parseOhOperationV1(JSON.parse(duplicate.operation_json)); - if (existing === null) - throw new OhIntegrityError("The stored idempotent operation is invalid."); + const existing = parseStoredOperationRow(duplicate, { operationId, spaceId: this.spaceId }); + this.#assertOperationReachable(existing, head); if (existing.actorId !== actorId || canonicalJson(existing.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return existing; } - const head = this.head(); if (head.generation !== input.expectedHead.generation || head.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current space head."); } @@ -2250,14 +2342,20 @@ class OhSqliteStore { if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); return withImmediateTransaction(this.database, () => { - const duplicate = this.database.query("SELECT operation_json FROM oh_operations WHERE operation_sha256 = ?").get(operation.operationSha256); + const head = this.head(); + this.#assertCurrentHeadAuthority(head); + const duplicate = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE operation_sha256 = ?`).get(operation.operationSha256); if (duplicate !== null) { - if (canonicalJson(JSON.parse(duplicate.operation_json)) !== canonicalJson(operation)) { + const existing = parseStoredOperationRow(duplicate, { + operationSha256: operation.operationSha256, + spaceId: this.spaceId + }); + this.#assertOperationReachable(existing, head); + if (canonicalJson(existing) !== canonicalJson(operation)) { throw new OhIntegrityError("An operation digest is bound to different bytes."); } return { imported: false, operation }; } - const head = this.head(); if (operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { throw new OhConflictError("The imported operation does not extend the local head."); } @@ -2275,13 +2373,9 @@ class OhSqliteStore { if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?").all(this.spaceId, afterSequence, boundedLimit); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null || canonicalJson(operation) !== row.operation_json) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`).all(this.spaceId, afterSequence, boundedLimit); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } #headAt(reference) { const parsed = parseOhHeadRefV1(reference); @@ -2289,20 +2383,11 @@ class OhSqliteStore { throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); - const row = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?").get(this.spaceId, parsed.sequence); + const row = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`).get(this.spaceId, parsed.sequence); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseStoredOperationRow(row, { spaceId: this.spaceId }); + if (operation.spaceId !== this.spaceId || operation.sequence !== parsed.sequence || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { @@ -2325,21 +2410,9 @@ class OhSqliteStore { const target = options.head === undefined ? current : this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence").all(this.spaceId, target.sequence); - const operations = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const operation = parseOhOperationV1(value); - if (operation === null) - throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`).all(this.spaceId, target.sequence); + const operations = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { throw new OhIntegrityError("Operation replay does not reproduce the requested head."); @@ -2360,34 +2433,23 @@ class OhSqliteStore { if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); } - const rows = this.database.query(`SELECT operation_json FROM oh_operations + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); - const parsed = rows.map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - return operation; - }); + const parsed = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); + if (parsed.length > limit + 1) + throw new OhIntegrityError("The change feed exceeded its requested page bound."); const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); - const first = operations[0]; - if (first !== undefined && (first.sequence !== fromHead.sequence + 1 || first.parentOperationSha256 !== fromHead.operationSha256)) { - throw new OhIntegrityError("The change feed does not extend its cursor."); - } - for (let index = 1;index < operations.length; index += 1) { - const prior = operations[index - 1]; - const operation = operations[index]; + let prior = fromHead; + for (const operation of parsed) { if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The change feed contains a gap or fork."); } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + if (!hasMore && (prior.sequence !== through.sequence || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The change feed does not reach its pinned through head."); } const last = operations.at(-1); const to = last === undefined ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } : { operationSha256: last.operationSha256, sequence: last.sequence }; @@ -2463,13 +2525,9 @@ class OhSqliteStore { } log(limit = 50) { this.#assertOpen(); - const rows = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence DESC LIMIT ?").all(this.spaceId, normalizeLimit(limit)); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null) - throw new OhIntegrityError("The stored operation is invalid."); - return operation; - }); + const rows = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? ORDER BY sequence DESC LIMIT ?`).all(this.spaceId, normalizeLimit(limit)); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } searchKeyword(query, limit = 20) { this.#assertOpen(); @@ -2525,20 +2583,7 @@ class OhSqliteStore { if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed."); const storedCount = this.database.query("SELECT count(*) AS count FROM oh_operations WHERE space_id = ?").get(this.spaceId)?.count ?? 0; - const operations = this.database.query("SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence").all(this.spaceId).map((row) => { - let value; - try { - value = JSON.parse(row.operation_json); - } catch { - throw new OhIntegrityError("A stored operation is not JSON."); - } - if (canonicalJson(value) !== row.operation_json) - throw new OhIntegrityError("A stored operation is not canonical JSON."); - const parsed = parseOhOperationV1(value); - if (parsed === null) - throw new OhIntegrityError("A stored operation is invalid."); - return parsed; - }); + const operations = this.database.query(`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? ORDER BY sequence`).all(this.spaceId).map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); @@ -2546,6 +2591,7 @@ class OhSqliteStore { #verifyOperations(operations) { const records = new Map; const materializedBy = new Map; + const operationIds = new Set; let head = { generation: 0, graphRevisionSha256: null, @@ -2555,8 +2601,9 @@ class OhSqliteStore { v: 1 }; for (const operation of operations) { - if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) + if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) throw new OhIntegrityError("Operation replay chain is broken."); + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") { records.set(change.record.key, change.record); @@ -2640,8 +2687,12 @@ class OhSqliteStore { throw new OhProfileError("Whole-space purge requires a bound working profile."); } return withImmediateTransaction(this.database, () => { - const row = this.database.query("SELECT binding_json FROM oh_space_bindings WHERE space_id = ?").get(this.spaceId); - if (row === null || row.binding_json !== canonicalJson(binding)) { + const row = this.database.query(`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`).get(this.spaceId); + if (row === null) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const persistedBinding = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persistedBinding) !== canonicalJson(binding)) { throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); } const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); @@ -2659,6 +2710,33 @@ class OhSqliteStore { this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + const directTables = [ + "oh_spaces", + "oh_space_bindings", + "oh_operations", + "oh_records", + "oh_dependencies", + "oh_search_documents", + "oh_sync_outbox", + "oh_sync_state" + ]; + for (const table of directTables) { + const count = this.database.query(`SELECT count(*) AS count FROM ${table} WHERE space_id = ?`).get(this.spaceId)?.count; + if (count !== 0) + throw new OhIntegrityError(`Space purge left rows in ${table}.`); + } + const operationRecords = this.database.query(`SELECT count(*) AS count + FROM oh_operation_records AS materialized JOIN oh_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ?`).get(this.spaceId)?.count; + const searchRows = this.database.query("SELECT count(*) AS count FROM oh_search_fts WHERE space_id = ?").get(this.spaceId)?.count; + if (operationRecords !== 0 || searchRows !== 0) { + throw new OhIntegrityError("Space purge left derived private payload rows."); + } + const receiptRow = this.database.query(`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`).get(this.spaceId); + if (receiptRow === null || canonicalJson(parseStoredPurgeRow(receiptRow, this.spaceId)) !== canonicalJson(receipt)) { + throw new OhIntegrityError("The stored purge receipt differs from the requested purge."); + } return receipt; }); } diff --git a/dist/sqlite/store.d.ts.map b/dist/sqlite/store.d.ts.map index 48b59d7..6e19c09 100644 --- a/dist/sqlite/store.d.ts.map +++ b/dist/sqlite/store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sqlite/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/store.d.ts b/dist/store.d.ts index af5b4ea..bbac4ab 100644 --- a/dist/store.d.ts +++ b/dist/store.d.ts @@ -1,5 +1,6 @@ import { type Sha256Hex } from "./canonical"; -import { type OhRecordCodecRegistry } from "./contract"; +import { OhRecordCodecRegistry } from "./contract"; +export { OhRecordCodecRegistry } from "./contract"; import { type KnowledgeGraphChangeV1, type KnowledgeGraphRecordKindV1, type KnowledgeGraphRecordV1 } from "./graph"; import { type OhOperationV1 } from "./operation"; export declare class OhConflictError extends Error { @@ -186,6 +187,21 @@ export declare function verifyOhDependencyClosureV1(value: unknown): Readonly<{ ok: false; reason: "invalid-closure"; }>; +/** + * Strong adoption check. Unlike structural self-verification, this also binds + * the capsule to the exact store binding and head selected by trusted host code. + */ +export declare function verifyOhDependencyClosureAgainstV1(value: unknown, expected: Readonly<{ + binding: OhStoreBindingV1; + head: OhHeadV1; +}>): Readonly<{ + closure: OhDependencyClosureV1; + ok: true; + verification: "expected-authority-and-head"; +}> | Readonly<{ + ok: false; + reason: "binding-mismatch" | "head-mismatch" | "invalid-closure" | "invalid-expectation"; +}>; export declare function createOhSpacePurgeReceiptV1(input: Readonly<{ binding: OhStoreBindingV1; priorHead: OhHeadV1; @@ -217,5 +233,4 @@ export declare class OhSemanticBundleIngressV1 { constructor(store: OhStoreV1, codecs: OhRecordCodecRegistry); commit(value: unknown): Promise; } -export {}; //# sourceMappingURL=store.d.ts.map \ No newline at end of file diff --git a/dist/store.d.ts.map b/dist/store.d.ts.map index acf9b38..367ef4f 100644 --- a/dist/store.d.ts.map +++ b/dist/store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/store.js b/dist/store.js index 197519f..18e912a 100644 --- a/dist/store.js +++ b/dist/store.js @@ -100,6 +100,24 @@ function encodeCanonical(value, path, ancestors) { function canonicalJson(value) { return encodeCanonical(value, "$", new Set); } +function parseCanonicalJson(text, maximumBytes = 16 * 1024 * 1024) { + if (utf8ByteLength(text) > maximumBytes) { + throw new OhValidationError("limit-exceeded", "$", "canonical JSON exceeds its byte limit"); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new OhValidationError("invalid-json", "$", "is not valid JSON"); + } + if (canonicalJson(value) !== text) { + throw new OhValidationError("noncanonical-json", "$", "keys or values are not canonical"); + } + return value; +} +function utf8ByteLength(value) { + return Buffer.byteLength(value, "utf8"); +} function sha256Hex(value) { return createHash("sha256").update(value).digest("hex"); } @@ -317,7 +335,6 @@ class OhRecordCodecRegistry { return this.#sealed; } } - // src/operation.ts var OH_OPERATION_MAX_BYTES_V1 = 64 * 1024 * 1024; function parsePayload(value) { @@ -617,12 +634,14 @@ function replayOhOperationsV1(spaceId, values, maximumRecords = OH_GRAPH_LIMITS_ throw new TypeError("Invalid operation replay input."); } const records = new Map; + const operationIds = new Set; let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); - if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { + if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256 || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); @@ -746,9 +765,10 @@ function normalizeRoots(values) { } return sorted; } -function closureRecords(available, roots, maximumRecords) { +function closureRecords(available, roots, maximumRecords, maximumBytes = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024) { const selected = new Map; const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop(); if (selected.has(key)) @@ -756,6 +776,9 @@ function closureRecords(available, roots, maximumRecords) { const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) + throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); @@ -829,6 +852,20 @@ function verifyOhDependencyClosureV1(value) { const closure = parseOhDependencyClosureV1(value); return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +function verifyOhDependencyClosureAgainstV1(value, expected) { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) + return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) + return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) + return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) + return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} function createOhSpacePurgeReceiptV1(input) { const binding = parseOhStoreBindingV1(input.binding); const priorHead = parseOhHeadV1(input.priorHead); @@ -932,6 +969,7 @@ class OhSemanticBundleIngressV1 { } export { verifyOhDependencyClosureV1, + verifyOhDependencyClosureAgainstV1, transitionOhSnapshotV1, replayOhOperationsV1, parseOhStoreProfileV1, @@ -946,6 +984,7 @@ export { createOhSpacePurgeReceiptV1, createOhDependencyClosureV1, OhSemanticBundleIngressV1, + OhRecordCodecRegistry, OhPurgedSpaceError, OhProfileError, OhIntegrityError, diff --git a/package.json b/package.json index cd6e253..7fb0051 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/oh", - "version": "0.1.1", + "version": "0.2.0", "description": "open-source tools for agentic research", "type": "module", "license": "MIT", @@ -68,6 +68,10 @@ "types": "./dist/projection-suss.d.ts", "import": "./dist/projection-suss.js" }, + "./experimental/memory": { + "types": "./dist/memory.d.ts", + "import": "./dist/memory.js" + }, "./package.json": "./package.json" }, "files": [ @@ -84,10 +88,11 @@ "scripts": { "build": "bun run build:js && bun run build:portable && bun run build:types", "build:js": "bun build ./src/index.ts ./src/sdk.ts ./src/sqlite/index.ts ./src/sync.ts ./src/semantic.ts ./src/cli.ts --outdir ./dist --target bun --format esm --external bun:sqlite", - "build:portable": "bun build ./src/store.ts ./src/libsql.ts ./src/projection-public.ts ./src/projection-suss.ts --outdir ./dist --target node --format esm --external @suss/datalog", + "build:portable": "bun build ./src/store.ts ./src/libsql.ts ./src/projection-public.ts ./src/projection-suss.ts ./src/memory.ts --outdir ./dist --target node --format esm --external @suss/datalog", "build:types": "tsc -p tsconfig.build.json", - "check": "bun run typecheck && bun run test && bun run build && bun run test:node && bun run test:node-projection", + "check": "bun run typecheck && bun run test && bun run build && bun run test:types:node && bun run test:node && bun run test:node-projection", "test:node": "node ./tests/node-portable.mjs", + "test:types:node": "tsc -p tsconfig.node-portable.json --noEmit", "test": "bun test ./src ./tests ./site/tests/source.test.ts", "test:node-projection": "node --test ./scripts/projection-node.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/scripts/projection-node.test.mjs b/scripts/projection-node.test.mjs index 33d414c..d691155 100644 --- a/scripts/projection-node.test.mjs +++ b/scripts/projection-node.test.mjs @@ -14,6 +14,8 @@ import { createOhProjectionSnapshotV1, evaluateOhProjectionV1, ohProjectionVariableV1, + parseOhProjectionProofV1, + parseOhProjectionResultV1, } from "../dist/projection-public.js"; const sha256 = (value) => createHash("sha256").update(value).digest("hex"); @@ -36,6 +38,8 @@ test("the projection subpath runs under Node without loading the SQLite runtime" const result = evaluateOhProjectionV1({ dataset, query, rulePack, snapshot }); assert.deepEqual(result.rows, []); assert.equal(result.authority, "derived"); + assert.deepEqual(parseOhProjectionResultV1(result), result); + assert.equal(parseOhProjectionProofV1({ unexpected: true }), null); assert.equal(Object.hasOwn(projectionSurface, "evaluateOhProjectionWithMaterializerV1"), false); const sources = await Promise.all(["projection-public.js", "projection-suss.js"] diff --git a/site/app/spec/page.tsx b/site/app/spec/page.tsx index 8d493d5..dd98a97 100644 --- a/site/app/spec/page.tsx +++ b/site/app/spec/page.tsx @@ -15,7 +15,7 @@ const specificationDescription = export const metadata: Metadata = { title: specificationTitle, description: - "The versioned contract for Oh records, graph revisions, local SQLite authority, synchronization, and semantic search.", + "The versioned contract for Oh records, graph revisions, SQLite and libSQL authority, projections, and composite memory.", alternates: { canonical: "/spec" }, openGraph: { title: specificationTitle, @@ -44,6 +44,7 @@ const sections = [ ["sync", "Sync"], ["semantic", "Semantic search"], ["projection", "Derived projection"], + ["memory", "Composite memory"], ["versioning", "Versioning"], ] as const; @@ -225,8 +226,27 @@ export default function Specification() {
-
+
08
+
+

Composite agent memory

+

+ One experimental facade composes a separately governed working + authority with one exact canonical head. Host-purposed named + programs see lane-tagged facts, visible conflicts, exact + physical authority and extractor digests, and bounded proofs + without receiving store locators or canonical mutation handles. +

+

+ Working nominations are verified dependency-closure proposals. + Review and durable adoption remain destination-owned operations; + a derived result never promotes itself. +

+
+
+ +
+
09

Versioning and evolution

diff --git a/site/package.json b/site/package.json index 6b0dd49..da6e344 100644 --- a/site/package.json +++ b/site/package.json @@ -1,6 +1,6 @@ { "name": "oh-site", - "version": "0.1.1", + "version": "0.2.0", "private": true, "packageManager": "bun@1.3.14", "engines": { diff --git a/site/public/spec/README.md b/site/public/spec/README.md index 85b3fbf..33c56aa 100644 --- a/site/public/spec/README.md +++ b/site/public/spec/README.md @@ -17,6 +17,8 @@ binds these versions: | SQLite schema | `2` | | Sync protocol | `oh.sync.v1` | | Embedding profile | `1` | +| Projection semantics | `oh.projection.positive-datalog.v1` | +| Composite memory | `experimental v1` | ## V1 documents @@ -29,6 +31,7 @@ binds these versions: - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) - [Derived projections](v1/projection.md) +- [Experimental composite agent memory](v1/memory.md) - [Compatibility and migration](v1/migration.md) Machine-readable V1 artifacts: @@ -41,6 +44,10 @@ Machine-readable V1 artifacts: - [`schema-revision.schema.json`](v1/schema-revision.schema.json) - [`operation.schema.json`](v1/operation.schema.json) - [`sync-bundle.schema.json`](v1/sync-bundle.schema.json) +- [`projection-rule-pack.schema.json`](v1/projection-rule-pack.schema.json) +- [`projection-query.schema.json`](v1/projection-query.schema.json) +- [`projection-identity.schema.json`](v1/projection-identity.schema.json) +- [`projection-result.schema.json`](v1/projection-result.schema.json) ## Conformance diff --git a/site/public/spec/manifest.json b/site/public/spec/manifest.json index f470d9d..6c28d11 100644 --- a/site/public/spec/manifest.json +++ b/site/public/spec/manifest.json @@ -11,13 +11,27 @@ "contractSha256": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594", "embeddingProfile": "./v1/embedding-profile.json", "id": "v1", + "memory": { + "specification": "./v1/memory.md" + }, "ontology": "./v1/ontology.json", + "projection": { + "identitySchema": "./v1/projection-identity.schema.json", + "querySchema": "./v1/projection-query.schema.json", + "resultSchema": "./v1/projection-result.schema.json", + "rulePackSchema": "./v1/projection-rule-pack.schema.json", + "specification": "./v1/projection.md" + }, "schemas": [ "./v1/contract.schema.json", "./v1/record.schema.json", "./v1/schema-revision.schema.json", "./v1/operation.schema.json", - "./v1/sync-bundle.schema.json" + "./v1/sync-bundle.schema.json", + "./v1/projection-rule-pack.schema.json", + "./v1/projection-query.schema.json", + "./v1/projection-identity.schema.json", + "./v1/projection-result.schema.json" ], "specification": "./v1/ontology.md", "status": "current", diff --git a/site/public/spec/v1/memory.md b/site/public/spec/v1/memory.md new file mode 100644 index 0000000..49b6199 --- /dev/null +++ b/site/public/spec/v1/memory.md @@ -0,0 +1,114 @@ +# Experimental composite agent memory + +`@hraness/oh/experimental/memory` is the first consumer-facing composition of +the stable store and projection contracts. It is experimental API, not a new +ontology or a third storage authority. + +## One kernel, two authorities + +A host MUST bind two distinct physical authorities before it creates the +facade: + +- a working-profile store, writable only through codec-enforced semantic + bundles; and +- a canonical-profile store pinned at one exact host-selected head and never + writable through the returned agent object. + +The host supplies opaque authority IDs plus the exact expected binding digests. +The factory rejects equal authority IDs, a binding mismatch, or the wrong +profile. Authority IDs identify custody in results without revealing a database +path, URL, credential, or purge handle. An Oh space, a semantic context, a +runtime tenant/session, and a physical authority remain different boundaries. +The host also binds the working actor, every named program's purpose, and every +named nomination route. None of those authority-bearing labels comes from +agent input. + +The returned object exposes only `remember`, `query`, `explain`, and +`nominate`. It has no generic commit, store selection, path, sync, rule +registration, canonical write, or purge operation. + +`remember` accepts only an expected working head, semantic puts and tombstones, +and an idempotency request ID. The facade supplies its host-bound actor, uses +its non-regressing host clock, derives the operation ID, and returns an +immutable, locator-free receipt with the working authority ID, binding digest, +resulting complete head, operation digest, actor, and actual instant. It never +returns raw operation changes or accepts a caller-asserted actor or timestamp. + +## Composite projection + +Every query reads the current working head and the factory-pinned canonical +head. It builds a disposable composite dataset with lane-tagged +`memory.record` and `memory.dependency` facts. Equal logical keys with different +record digests produce an explicit `memory.conflict` fact and result entry; +working data never shadows canonical data by recency. Equal digests produce +`memory.agreement`. + +Trusted host code may register digest-identified domain fact extractors. Each +extractor declares a disjoint set of relations it owns. The facade supplies a +deeply immutable record and its exact physical source, bounds invocation and +output counts, and forbids custom ownership of reserved `memory.*` or `oh.*` +relations. Every fact proof identifies the built-in fact pack or exact domain +extractor ID and digest that emitted it. Trusted host code also registers a +bounded set of parsed rule-pack/query pairs and their fixed purposes under +names. Agent input selects a name; it cannot submit a purpose, rule, query AST, +or inert validity label. + +The composite identity binds: + +- both opaque authority IDs, binding digests, complete heads, projection + snapshots, and lane dataset digests; +- the composite fact dataset; +- the named program, exact rule pack and query; +- evaluation and engine identity inherited from the projection result; +- the host-bound program purpose and fixed visible-conflict policy. + +Changing any of these values produces a different memory digest and a full +rebuild. Results and proofs remain `derived`. A rule cannot upgrade the +authority of its premises. Each public row is labeled from its visible physical +premise lanes; a truncated or missing witness is `unknown` rather than +silently canonical. Returned result, row, value, proof, source, and receipt +graphs are detached and deeply immutable, so a caller cannot mutate bytes after +their digest or explanation capability is issued. + +## Explanations and nominations + +Query returns an opaque, random, short-lived explanation capability bound to +that exact deterministic result. Its expiry uses a non-regressing monotonic +clock; the wall-clock instant is display metadata. A capability may explain +multiple rows until expiry or eviction. Count, per-entry bytes, and aggregate +retained evidence are bounded. `explain` also requires the exact result digest +and row index. It maps every projection fact witness back to an authority ID, +binding digest, complete pinned head, lane, original record key and digest. A +wrong, expired, evicted, or result-mismatched capability fails closed. + +`nominate` selects one host-registered route by opaque name, then exports and +re-verifies an exact dependency closure and exact requested root set from the +current working head. Its +output is only a content-addressed `prepared` proposal for that route's fixed +destination purpose. It does not sync the working operation chain, mutate the +canonical store, import a derived tuple, grant rights, record a review, or turn +a proposed assertion into reviewed knowledge. Destination-owned application +code must perform those steps under its own policy and compare-and-swap head. + +## Lifecycle and custody boundary + +Oh deliberately does not choose a tenant, session, retention deadline, +physical database, credential, scheduler, or backup policy. The application +host owns those lifecycle controls and retains the separate working store host +object. Purge removes a working authority through that host-only +capability; tombstoning is not erasure because operation history retains prior +bytes. Database credentials or same-UID filesystem access remain outside this +API boundary. + +The facade requests at most 8,192 records per lane, rejects a lane snapshot +over 32 MiB, rejects a remember request over 8 MiB, bounds extractor +invocations and emitted facts, limits the public result to 32 MiB, and retains +at most 64 MiB of explanation evidence. A trusted store still constructs the +snapshot before returning it, and a trusted synchronous fact extractor can +consume time or temporary memory before it returns. Provider response limits, +host storage quotas, callback review, isolation, deadlines, and cancellation +remain application responsibilities. + +Suss is an optional differential evaluator behind the separate projection +compatibility subpath. The memory facade uses the package-owned bounded +reference evaluator. Cozo is neither evaluated nor loaded. diff --git a/site/public/spec/v1/projection-identity.schema.json b/site/public/spec/v1/projection-identity.schema.json new file mode 100644 index 0000000..485e724 --- /dev/null +++ b/site/public/spec/v1/projection-identity.schema.json @@ -0,0 +1,58 @@ +{ + "$defs": { + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + } + }, + "$id": "https://oh.computer/spec/v1/projection-identity.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance requires the exact public contract digest and recomputes projectionSha256 over every identity field other than projectionSha256 itself.", + "additionalProperties": false, + "properties": { + "contractSha256": { + "const": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594" + }, + "datasetSha256": { + "$ref": "#/$defs/sha256" + }, + "engineSha256": { + "$ref": "#/$defs/sha256" + }, + "evaluationSha256": { + "$ref": "#/$defs/sha256" + }, + "projectionSha256": { + "$ref": "#/$defs/sha256" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "rulePackSha256": { + "$ref": "#/$defs/sha256" + }, + "semantics": { + "const": "oh.projection.positive-datalog.v1" + }, + "snapshotSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "contractSha256", + "datasetSha256", + "engineSha256", + "evaluationSha256", + "projectionSha256", + "querySha256", + "rulePackSha256", + "semantics", + "snapshotSha256", + "v" + ], + "title": "Oh projection identity V1", + "type": "object" +} diff --git a/site/public/spec/v1/projection-query.schema.json b/site/public/spec/v1/projection-query.schema.json new file mode 100644 index 0000000..d9db1e5 --- /dev/null +++ b/site/public/spec/v1/projection-query.schema.json @@ -0,0 +1,60 @@ +{ + "$defs": { + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + } + }, + "$id": "https://oh.computer/spec/v1/projection-query.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally requires unique projected variables, binds every projected variable in the body, enforces the 256-variable aggregate ceiling, and recomputes querySha256.", + "additionalProperties": false, + "properties": { + "find": { + "items": { + "$ref": "#/$defs/safeCode" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "limit": { + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "queryId": { + "$ref": "#/$defs/safeCode" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + }, + "where": { + "items": { + "$ref": "./projection-rule-pack.schema.json#/$defs/literal" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "find", + "limit", + "queryId", + "querySha256", + "where", + "v" + ], + "title": "Oh projection query V1", + "type": "object" +} diff --git a/site/public/spec/v1/projection-result.schema.json b/site/public/spec/v1/projection-result.schema.json new file mode 100644 index 0000000..9c0cf93 --- /dev/null +++ b/site/public/spec/v1/projection-result.schema.json @@ -0,0 +1,452 @@ +{ + "$defs": { + "atom": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "maxLength": 16382, + "type": "string" + } + ] + }, + "derivedProof": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "premisesTruncated": { + "const": false + } + }, + "required": [ + "premisesTruncated" + ] + }, + "then": { + "properties": { + "premises": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "premisesTruncated": { + "const": true + } + }, + "required": [ + "premisesTruncated" + ] + }, + "then": { + "properties": { + "premises": { + "maxItems": 63 + } + } + } + } + ], + "properties": { + "kind": { + "const": "derived" + }, + "premises": { + "items": { + "$ref": "#/$defs/proof" + }, + "maxItems": 64, + "type": "array" + }, + "premisesTruncated": { + "type": "boolean" + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "ruleId": { + "$ref": "#/$defs/safeCode" + }, + "ruleSha256": { + "$ref": "#/$defs/sha256" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "premises", + "premisesTruncated", + "relation", + "ruleId", + "ruleSha256", + "tuple", + "v" + ], + "type": "object" + }, + "evaluation": { + "additionalProperties": false, + "properties": { + "maximumDerivedTuples": { + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, + "maximumProofDepth": { + "maximum": 128, + "minimum": 1, + "type": "integer" + }, + "maximumProofNodes": { + "maximum": 4096, + "minimum": 1, + "type": "integer" + }, + "maximumResultBytes": { + "maximum": 16777216, + "minimum": 65536, + "type": "integer" + }, + "maximumRounds": { + "maximum": 1024, + "minimum": 1, + "type": "integer" + }, + "maximumTotalProofNodes": { + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "maximumWorkUnits": { + "maximum": 16777216, + "minimum": 1, + "type": "integer" + }, + "v": { + "const": 1 + } + }, + "required": [ + "maximumDerivedTuples", + "maximumProofDepth", + "maximumProofNodes", + "maximumResultBytes", + "maximumRounds", + "maximumTotalProofNodes", + "maximumWorkUnits", + "v" + ], + "type": "object" + }, + "engineCode": { + "maxLength": 256, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "factProof": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "fact" + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "sources": { + "items": { + "$ref": "#/$defs/source" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "relation", + "sources", + "tuple", + "v" + ], + "type": "object" + }, + "proof": { + "oneOf": [ + { + "$ref": "#/$defs/factProof" + }, + { + "$ref": "#/$defs/derivedProof" + }, + { + "$ref": "#/$defs/truncatedProof" + } + ] + }, + "row": { + "additionalProperties": false, + "properties": { + "proofs": { + "items": { + "$ref": "#/$defs/proof" + }, + "maxItems": 64, + "type": "array" + }, + "proofsTruncated": { + "type": "boolean" + }, + "supportCount": { + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, + "values": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "proofs", + "proofsTruncated", + "supportCount", + "values", + "v" + ], + "type": "object" + }, + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "key": { + "maxLength": 512, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "recordSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "key", + "recordSha256", + "v" + ], + "type": "object" + }, + "stats": { + "additionalProperties": false, + "properties": { + "baseFacts": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "derivedFacts": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "proofNodes": { + "maximum": 65536, + "minimum": 0, + "type": "integer" + }, + "proofsTruncated": { + "type": "boolean" + }, + "queryMatches": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "relations": { + "maximum": 4096, + "minimum": 0, + "type": "integer" + }, + "rounds": { + "maximum": 1024, + "minimum": 0, + "type": "integer" + }, + "truncated": { + "type": "boolean" + }, + "truncationReasons": { + "items": { + "enum": [ + "query-limit", + "result-bytes" + ] + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "v": { + "const": 1 + }, + "workUnits": { + "maximum": 16777216, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "baseFacts", + "derivedFacts", + "proofNodes", + "proofsTruncated", + "queryMatches", + "relations", + "rounds", + "truncated", + "truncationReasons", + "v", + "workUnits" + ], + "type": "object" + }, + "truncatedProof": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "truncated" + }, + "reason": { + "enum": [ + "cycle", + "depth", + "nodes" + ] + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "reason", + "relation", + "tuple", + "v" + ], + "type": "object" + }, + "tuple": { + "items": { + "$ref": "#/$defs/atom" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + } + }, + "$id": "https://oh.computer/spec/v1/projection-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally checks canonical row and source order, atom byte limits, declared aggregate limits, proof depth and node totals, truncation consistency, engineSha256, evaluationSha256, projectionSha256, and resultSha256.", + "additionalProperties": false, + "properties": { + "authority": { + "const": "derived" + }, + "cache": { + "additionalProperties": false, + "properties": { + "strategy": { + "const": "full-rebuild" + }, + "v": { + "const": 1 + } + }, + "required": [ + "strategy", + "v" + ], + "type": "object" + }, + "engine": { + "$ref": "#/$defs/engineCode" + }, + "evaluation": { + "$ref": "#/$defs/evaluation" + }, + "identity": { + "$ref": "./projection-identity.schema.json" + }, + "resultSha256": { + "$ref": "#/$defs/sha256" + }, + "rows": { + "items": { + "$ref": "#/$defs/row" + }, + "maxItems": 65536, + "type": "array" + }, + "stats": { + "$ref": "#/$defs/stats" + }, + "v": { + "const": 1 + } + }, + "required": [ + "authority", + "cache", + "engine", + "evaluation", + "identity", + "resultSha256", + "rows", + "stats", + "v" + ], + "title": "Oh projection result V1", + "type": "object" +} diff --git a/site/public/spec/v1/projection-rule-pack.schema.json b/site/public/spec/v1/projection-rule-pack.schema.json new file mode 100644 index 0000000..4864012 --- /dev/null +++ b/site/public/spec/v1/projection-rule-pack.schema.json @@ -0,0 +1,182 @@ +{ + "$defs": { + "atom": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "maxLength": 16382, + "type": "string" + } + ] + }, + "constantTerm": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "constant" + }, + "v": { + "const": 1 + }, + "value": { + "$ref": "#/$defs/atom" + } + }, + "required": [ + "kind", + "v", + "value" + ], + "type": "object" + }, + "literal": { + "additionalProperties": false, + "properties": { + "relation": { + "$ref": "#/$defs/safeCode" + }, + "terms": { + "items": { + "$ref": "#/$defs/term" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + }, + "v": { + "const": 1 + } + }, + "required": [ + "relation", + "terms", + "v" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "body": { + "items": { + "$ref": "#/$defs/literal" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "head": { + "$ref": "#/$defs/literal" + }, + "ruleId": { + "$ref": "#/$defs/safeCode" + }, + "ruleSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "body", + "head", + "ruleId", + "ruleSha256", + "v" + ], + "type": "object" + }, + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "term": { + "oneOf": [ + { + "$ref": "#/$defs/constantTerm" + }, + { + "$ref": "#/$defs/variableTerm" + } + ] + }, + "variableTerm": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "variable" + }, + "name": { + "$ref": "#/$defs/safeCode" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "name", + "v" + ], + "type": "object" + } + }, + "$id": "https://oh.computer/spec/v1/projection-rule-pack.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally measures each atom's exact 16 KiB canonical-JSON byte ceiling, requires safe head variables, canonical rule ordering, unique rule IDs, and recomputed rule and pack digests.", + "additionalProperties": false, + "properties": { + "rulePackId": { + "$ref": "#/$defs/safeCode" + }, + "rulePackRevision": { + "minimum": 1, + "type": "integer" + }, + "rulePackSha256": { + "$ref": "#/$defs/sha256" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array" + }, + "rulesSha256": { + "$ref": "#/$defs/sha256" + }, + "semantics": { + "const": "oh.projection.positive-datalog.v1" + }, + "v": { + "const": 1 + } + }, + "required": [ + "rulePackId", + "rulePackRevision", + "rulePackSha256", + "rules", + "rulesSha256", + "semantics", + "v" + ], + "title": "Oh projection rule pack V1", + "type": "object" +} diff --git a/site/public/spec/v1/projection.md b/site/public/spec/v1/projection.md index 6c3c183..fe32ab7 100644 --- a/site/public/spec/v1/projection.md +++ b/site/public/spec/v1/projection.md @@ -64,6 +64,9 @@ Rule packs are sorted by rule ID and content-addressed. A query declares an ordered `find` variable list, a nonempty positive body, and an output limit. Query results use set semantics and sort tuples by canonical JSON. Declaration, fact, and insertion order do not affect rule-pack identity or output bytes. +The projection identity also binds the selected engine and resolved evaluation +limits, so results created with different engines, proof budgets, or work +budgets cannot share a cache identity. ## Evaluation limits @@ -79,31 +82,70 @@ The implementation checks hard ceilings before or during work: | Body literals per rule | 64 | | Evaluation rounds | 1,024 | | Join matches per rule or query body | 262,144 | +| Tuple-unification work units per evaluation | 16,777,216 | | Returned query rows | 65,536 | | Proof depth | 128 | | Proof nodes per row | 4,096 | - -A caller may request smaller derived-tuple, round, proof-depth, and proof-node -bounds. Exceeding a work bound fails closed. A query's declared output limit -returns the first canonical tuples and reports `stats.truncated: true` when more -distinct answers exist. +| Proof nodes across returned rows | 65,536 | +| Canonical result bytes | 16 MiB | + +A caller may request smaller derived-tuple, round, proof-depth, proof-node, and +global tuple-unification work bounds. The global work counter spans every rule, +round, and the final query, including unsuccessful candidate matches. Exceeding +a work bound fails closed. Result construction additionally stops before the +aggregate proof-node or canonical-byte ceilings. A query's declared output +limit or the result-byte ceiling returns a canonical prefix, sets +`stats.truncated: true`, and lists `query-limit` or `result-bytes` in +`stats.truncationReasons`. ## Proofs -Each returned row carries one proof for each query-body match. A fact leaf names -its relation, tuple, and exact source record references. A derived node names -the rule ID and digest and recursively contains its premises. Depth, node, and -cycle guards emit an explicit `truncated` node. A proof establishes how the -bounded evaluator derived a tuple from the supplied bytes; it does not establish -that a proposition is true. +Each returned row carries one proof for each literal in one canonical supporting +query-body match. `supportCount` reports how many complete matches produced the +same projected value tuple; V1 deliberately does not serialize every alternate +witness. A fact leaf names its relation, tuple, and exact source record +references. A derived node names the rule ID and digest and recursively contains +its premises. Depth and cycle guards emit an explicit `truncated` node. If a +node or byte budget ends between sibling premises, the enclosing derived node +sets `premisesTruncated: true`; if it ends between query-body proofs, the row +sets `proofsTruncated: true`. +`stats.proofsTruncated` reports either form across all returned rows. A proof +establishes how the bounded evaluator derived a tuple from the supplied bytes; +it does not establish that a proposition is true. + +## Safe cached ingress + +Projection declarations and cache output are untrusted exchange data. The +`parseOhProjectionRulePackV1`, `parseOhProjectionQueryV1`, and +`parseOhProjectionIdentityV1` parsers reject unknown keys and invalid digest +preimages. `parseOhProjectionProofV1` additionally applies the public proof +depth, node, tuple, source, atom-byte, and aggregate-byte ceilings before +returning a proof tree. + +`parseOhProjectionResultV1` is the cache-ingress boundary. It verifies the +result digest; canonical row and source order; `supportCount`; proof-node, +work-unit, relation, match, round, and byte totals; every proof and result +truncation marker; and all declared evaluation ceilings. It also recomputes the +engine and evaluation digests and requires them to match the projection +identity. A valid SHA-256 string by itself is not enough to make an envelope +acceptable. Cache readers SHOULD pass the projection digest they requested as +the parser's second argument; an internally consistent envelope does not prove +that a cache returned the requested identity. + +The discovery manifest publishes machine-readable schemas for rule packs, +queries, identities, and result envelopes. JSON Schema describes the exchange +shape and static maxima. The runtime parsers remain normative for canonical +ordering, digest preimages, aggregate budgets, and cross-field consistency that +the schemas cannot express. ## Cache invalidation `projectionSha256` binds the current contract, snapshot, dataset, rule pack, -query, and positive-Datalog semantics. A cached result is reusable only when -that digest is unchanged. Any snapshot, dataset, rule-pack, or query change has -`kind: "full-rebuild"` and lists the changed identities. V1 does not claim -incremental deletion or cross-snapshot maintenance. +query, engine, resolved evaluation limits, and positive-Datalog semantics. A cached +result is reusable only when that digest is unchanged. Any snapshot, dataset, +rule-pack, query, engine, or evaluation-limit change has `kind: "full-rebuild"` and +lists the changed identities. V1 does not claim incremental deletion or +cross-snapshot maintenance. ## Optional Suss equivalence lane @@ -114,8 +156,10 @@ every complete relation to the Oh reference semantics. It returns only after exact set agreement. Suss's public evaluator does not expose an execution-budget hook. Before calling -it, the adapter computes a conservative finite-domain upper bound and refuses a -program it cannot prove will remain under the requested derived-tuple ceiling. -It then runs the bounded reference evaluator for equivalence and canonical proof -construction. This lane evaluates compatibility, not performance. Refusal does -not disable the built-in evaluator. +it, the adapter first runs the bounded reference evaluator, then computes a +conservative finite-domain upper bound on new tuples in rule-head relations and +refuses a program it cannot prove will remain under the requested derived-tuple +ceiling. It compares Suss's complete result to the reference materialization and +uses the reference witnesses for canonical proof construction. This lane +evaluates compatibility, not performance. Refusal does not disable the built-in +evaluator. diff --git a/site/public/spec/v1/store.md b/site/public/spec/v1/store.md index da7bc4c..85aa95e 100644 --- a/site/public/spec/v1/store.md +++ b/site/public/spec/v1/store.md @@ -69,6 +69,12 @@ Run it in a deployment or migration step with a short-lived schema credential. installed schema and contract before reading or creating a bound data space, so a runtime token does not need schema-change permission. +Runtime open verifies the exact installed table, index, and trigger set, not +only a schema marker. Every operation, binding, and purge receipt read parses +its canonical JSON and cross-checks each duplicated SQL column. Current reads +also prove contiguous operation coverage through the exact terminal head, +record provenance puts, record digests, and dependency materialization. + Its private implementation tables use the `oh_authority_` prefix: | Table | Role | @@ -84,13 +90,28 @@ Its private implementation tables use the `oh_authority_` prefix: | `oh_authority_purges` | Minimal whole-space purge receipts. | | `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. | -A remote commit reads one exact snapshot, computes the ordinary V1 operation, -then uses one write batch guarded by the expected head. The final guard aborts -the complete transaction when compare-and-swap did not settle at the declared -operation. The adapter re-reads and verifies the persisted canonical operation -before returning success. +A normal remote commit takes three atomic provider round trips: an idempotency, +head, and purge preflight; one exact current-materialization read; and one +guarded write batch. The final write guard aborts the complete transaction when +compare-and-swap did not settle at the declared operation. Its write-batch +readback must reproduce both the canonical operation and persisted head before +the adapter returns success. + +Provider responses are bounded as part of the API. V1 accepts at most 64 +changes, 512 dependencies, and 512 KiB of canonical operation JSON per commit. +A feed returns at most seven operations plus one checked sentinel and refuses a +page whose conservative transport estimate exceeds 9,000,000 bytes. Historical +replay is limited to 16,384 operations, 4 MiB of canonical operation JSON, and +the same response estimate. Current snapshot and full-verification result sets +are independently transport-estimated and SQL-gated before rows are returned. +Sizing, rows, and the pinned head are read in the same transaction, so a +concurrent append cannot grow an unchecked response between preflight and read. Remote purge similarly inserts a receipt only for the expected working head, deletes every payload and materialization row under that receipt in the same write batch, and aborts if either the receipt or deletion is incomplete. A later open returns the stored purge receipt instead of recreating the space. +Operation-record deletion resolves ownership through the canonical operation; +the purge postcondition also rejects any global orphan or cross-space owner +mismatch. Purge receipts are immutable and intentionally retain only binding, +prior-head, and purge-event evidence. diff --git a/skills/oh/SKILL.md b/skills/oh/SKILL.md index a26f67c..7dd821f 100644 --- a/skills/oh/SKILL.md +++ b/skills/oh/SKILL.md @@ -30,7 +30,7 @@ oh --help oh version ``` -The supported CLI is `@hraness/oh@0.1.1` from the immutable `v0.1.1` GitHub +The supported CLI is `@hraness/oh@0.2.0` from the immutable `v0.2.0` GitHub tag. It requires Bun 1.3.14 or newer. The versioned contract is published at . @@ -167,6 +167,24 @@ and credential source. Never print credentials or embed them in records. Oh settles fast-forward histories only; preserve both logs when it reports a divergence. +## Use composite memory only through host bindings + +`@hraness/oh/experimental/memory` is an SDK-only surface. Do not let a model +construct its options. Trusted application code must bind two distinct +authority handles, exact binding digests, a pinned canonical head, working +codecs, a working actor, domain extractor relation ownership and digests, +host-purposed named rule/query programs, and named nomination routes before +giving the returned object to an agent. + +The agent-facing object may call only `remember`, `query`, `explain`, and +`nominate`. Never add a tool parameter for a database path or URL, authority, +realm, space, store profile, rule pack, raw query, sync destination, canonical +write, caller-asserted actor/time, or purge operation. Preserve lane, conflict, +fact-policy, and premise-authority labels in query output. Treat every result +as derived. A nomination may select only a host-registered route and is a +prepared dependency-closure candidate for destination-owned review, not +permission to write durable knowledge or import the working operation chain. + ## Finish with evidence Report the exact database and space, reads or mutations performed, final head diff --git a/spec/README.md b/spec/README.md index 85b3fbf..33c56aa 100644 --- a/spec/README.md +++ b/spec/README.md @@ -17,6 +17,8 @@ binds these versions: | SQLite schema | `2` | | Sync protocol | `oh.sync.v1` | | Embedding profile | `1` | +| Projection semantics | `oh.projection.positive-datalog.v1` | +| Composite memory | `experimental v1` | ## V1 documents @@ -29,6 +31,7 @@ binds these versions: - [Sync protocol](v1/sync.md) - [Local embedding profile](v1/embedding.md) - [Derived projections](v1/projection.md) +- [Experimental composite agent memory](v1/memory.md) - [Compatibility and migration](v1/migration.md) Machine-readable V1 artifacts: @@ -41,6 +44,10 @@ Machine-readable V1 artifacts: - [`schema-revision.schema.json`](v1/schema-revision.schema.json) - [`operation.schema.json`](v1/operation.schema.json) - [`sync-bundle.schema.json`](v1/sync-bundle.schema.json) +- [`projection-rule-pack.schema.json`](v1/projection-rule-pack.schema.json) +- [`projection-query.schema.json`](v1/projection-query.schema.json) +- [`projection-identity.schema.json`](v1/projection-identity.schema.json) +- [`projection-result.schema.json`](v1/projection-result.schema.json) ## Conformance diff --git a/spec/manifest.json b/spec/manifest.json index f470d9d..6c28d11 100644 --- a/spec/manifest.json +++ b/spec/manifest.json @@ -11,13 +11,27 @@ "contractSha256": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594", "embeddingProfile": "./v1/embedding-profile.json", "id": "v1", + "memory": { + "specification": "./v1/memory.md" + }, "ontology": "./v1/ontology.json", + "projection": { + "identitySchema": "./v1/projection-identity.schema.json", + "querySchema": "./v1/projection-query.schema.json", + "resultSchema": "./v1/projection-result.schema.json", + "rulePackSchema": "./v1/projection-rule-pack.schema.json", + "specification": "./v1/projection.md" + }, "schemas": [ "./v1/contract.schema.json", "./v1/record.schema.json", "./v1/schema-revision.schema.json", "./v1/operation.schema.json", - "./v1/sync-bundle.schema.json" + "./v1/sync-bundle.schema.json", + "./v1/projection-rule-pack.schema.json", + "./v1/projection-query.schema.json", + "./v1/projection-identity.schema.json", + "./v1/projection-result.schema.json" ], "specification": "./v1/ontology.md", "status": "current", diff --git a/spec/v1/memory.md b/spec/v1/memory.md new file mode 100644 index 0000000..49b6199 --- /dev/null +++ b/spec/v1/memory.md @@ -0,0 +1,114 @@ +# Experimental composite agent memory + +`@hraness/oh/experimental/memory` is the first consumer-facing composition of +the stable store and projection contracts. It is experimental API, not a new +ontology or a third storage authority. + +## One kernel, two authorities + +A host MUST bind two distinct physical authorities before it creates the +facade: + +- a working-profile store, writable only through codec-enforced semantic + bundles; and +- a canonical-profile store pinned at one exact host-selected head and never + writable through the returned agent object. + +The host supplies opaque authority IDs plus the exact expected binding digests. +The factory rejects equal authority IDs, a binding mismatch, or the wrong +profile. Authority IDs identify custody in results without revealing a database +path, URL, credential, or purge handle. An Oh space, a semantic context, a +runtime tenant/session, and a physical authority remain different boundaries. +The host also binds the working actor, every named program's purpose, and every +named nomination route. None of those authority-bearing labels comes from +agent input. + +The returned object exposes only `remember`, `query`, `explain`, and +`nominate`. It has no generic commit, store selection, path, sync, rule +registration, canonical write, or purge operation. + +`remember` accepts only an expected working head, semantic puts and tombstones, +and an idempotency request ID. The facade supplies its host-bound actor, uses +its non-regressing host clock, derives the operation ID, and returns an +immutable, locator-free receipt with the working authority ID, binding digest, +resulting complete head, operation digest, actor, and actual instant. It never +returns raw operation changes or accepts a caller-asserted actor or timestamp. + +## Composite projection + +Every query reads the current working head and the factory-pinned canonical +head. It builds a disposable composite dataset with lane-tagged +`memory.record` and `memory.dependency` facts. Equal logical keys with different +record digests produce an explicit `memory.conflict` fact and result entry; +working data never shadows canonical data by recency. Equal digests produce +`memory.agreement`. + +Trusted host code may register digest-identified domain fact extractors. Each +extractor declares a disjoint set of relations it owns. The facade supplies a +deeply immutable record and its exact physical source, bounds invocation and +output counts, and forbids custom ownership of reserved `memory.*` or `oh.*` +relations. Every fact proof identifies the built-in fact pack or exact domain +extractor ID and digest that emitted it. Trusted host code also registers a +bounded set of parsed rule-pack/query pairs and their fixed purposes under +names. Agent input selects a name; it cannot submit a purpose, rule, query AST, +or inert validity label. + +The composite identity binds: + +- both opaque authority IDs, binding digests, complete heads, projection + snapshots, and lane dataset digests; +- the composite fact dataset; +- the named program, exact rule pack and query; +- evaluation and engine identity inherited from the projection result; +- the host-bound program purpose and fixed visible-conflict policy. + +Changing any of these values produces a different memory digest and a full +rebuild. Results and proofs remain `derived`. A rule cannot upgrade the +authority of its premises. Each public row is labeled from its visible physical +premise lanes; a truncated or missing witness is `unknown` rather than +silently canonical. Returned result, row, value, proof, source, and receipt +graphs are detached and deeply immutable, so a caller cannot mutate bytes after +their digest or explanation capability is issued. + +## Explanations and nominations + +Query returns an opaque, random, short-lived explanation capability bound to +that exact deterministic result. Its expiry uses a non-regressing monotonic +clock; the wall-clock instant is display metadata. A capability may explain +multiple rows until expiry or eviction. Count, per-entry bytes, and aggregate +retained evidence are bounded. `explain` also requires the exact result digest +and row index. It maps every projection fact witness back to an authority ID, +binding digest, complete pinned head, lane, original record key and digest. A +wrong, expired, evicted, or result-mismatched capability fails closed. + +`nominate` selects one host-registered route by opaque name, then exports and +re-verifies an exact dependency closure and exact requested root set from the +current working head. Its +output is only a content-addressed `prepared` proposal for that route's fixed +destination purpose. It does not sync the working operation chain, mutate the +canonical store, import a derived tuple, grant rights, record a review, or turn +a proposed assertion into reviewed knowledge. Destination-owned application +code must perform those steps under its own policy and compare-and-swap head. + +## Lifecycle and custody boundary + +Oh deliberately does not choose a tenant, session, retention deadline, +physical database, credential, scheduler, or backup policy. The application +host owns those lifecycle controls and retains the separate working store host +object. Purge removes a working authority through that host-only +capability; tombstoning is not erasure because operation history retains prior +bytes. Database credentials or same-UID filesystem access remain outside this +API boundary. + +The facade requests at most 8,192 records per lane, rejects a lane snapshot +over 32 MiB, rejects a remember request over 8 MiB, bounds extractor +invocations and emitted facts, limits the public result to 32 MiB, and retains +at most 64 MiB of explanation evidence. A trusted store still constructs the +snapshot before returning it, and a trusted synchronous fact extractor can +consume time or temporary memory before it returns. Provider response limits, +host storage quotas, callback review, isolation, deadlines, and cancellation +remain application responsibilities. + +Suss is an optional differential evaluator behind the separate projection +compatibility subpath. The memory facade uses the package-owned bounded +reference evaluator. Cozo is neither evaluated nor loaded. diff --git a/spec/v1/projection-identity.schema.json b/spec/v1/projection-identity.schema.json new file mode 100644 index 0000000..485e724 --- /dev/null +++ b/spec/v1/projection-identity.schema.json @@ -0,0 +1,58 @@ +{ + "$defs": { + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + } + }, + "$id": "https://oh.computer/spec/v1/projection-identity.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance requires the exact public contract digest and recomputes projectionSha256 over every identity field other than projectionSha256 itself.", + "additionalProperties": false, + "properties": { + "contractSha256": { + "const": "e53ae573c2af417082be9f554d0f6f3e317f054daf745181f462608e3f622594" + }, + "datasetSha256": { + "$ref": "#/$defs/sha256" + }, + "engineSha256": { + "$ref": "#/$defs/sha256" + }, + "evaluationSha256": { + "$ref": "#/$defs/sha256" + }, + "projectionSha256": { + "$ref": "#/$defs/sha256" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "rulePackSha256": { + "$ref": "#/$defs/sha256" + }, + "semantics": { + "const": "oh.projection.positive-datalog.v1" + }, + "snapshotSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "contractSha256", + "datasetSha256", + "engineSha256", + "evaluationSha256", + "projectionSha256", + "querySha256", + "rulePackSha256", + "semantics", + "snapshotSha256", + "v" + ], + "title": "Oh projection identity V1", + "type": "object" +} diff --git a/spec/v1/projection-query.schema.json b/spec/v1/projection-query.schema.json new file mode 100644 index 0000000..d9db1e5 --- /dev/null +++ b/spec/v1/projection-query.schema.json @@ -0,0 +1,60 @@ +{ + "$defs": { + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + } + }, + "$id": "https://oh.computer/spec/v1/projection-query.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally requires unique projected variables, binds every projected variable in the body, enforces the 256-variable aggregate ceiling, and recomputes querySha256.", + "additionalProperties": false, + "properties": { + "find": { + "items": { + "$ref": "#/$defs/safeCode" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "limit": { + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "queryId": { + "$ref": "#/$defs/safeCode" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + }, + "where": { + "items": { + "$ref": "./projection-rule-pack.schema.json#/$defs/literal" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "find", + "limit", + "queryId", + "querySha256", + "where", + "v" + ], + "title": "Oh projection query V1", + "type": "object" +} diff --git a/spec/v1/projection-result.schema.json b/spec/v1/projection-result.schema.json new file mode 100644 index 0000000..9c0cf93 --- /dev/null +++ b/spec/v1/projection-result.schema.json @@ -0,0 +1,452 @@ +{ + "$defs": { + "atom": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "maxLength": 16382, + "type": "string" + } + ] + }, + "derivedProof": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "premisesTruncated": { + "const": false + } + }, + "required": [ + "premisesTruncated" + ] + }, + "then": { + "properties": { + "premises": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "premisesTruncated": { + "const": true + } + }, + "required": [ + "premisesTruncated" + ] + }, + "then": { + "properties": { + "premises": { + "maxItems": 63 + } + } + } + } + ], + "properties": { + "kind": { + "const": "derived" + }, + "premises": { + "items": { + "$ref": "#/$defs/proof" + }, + "maxItems": 64, + "type": "array" + }, + "premisesTruncated": { + "type": "boolean" + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "ruleId": { + "$ref": "#/$defs/safeCode" + }, + "ruleSha256": { + "$ref": "#/$defs/sha256" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "premises", + "premisesTruncated", + "relation", + "ruleId", + "ruleSha256", + "tuple", + "v" + ], + "type": "object" + }, + "evaluation": { + "additionalProperties": false, + "properties": { + "maximumDerivedTuples": { + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, + "maximumProofDepth": { + "maximum": 128, + "minimum": 1, + "type": "integer" + }, + "maximumProofNodes": { + "maximum": 4096, + "minimum": 1, + "type": "integer" + }, + "maximumResultBytes": { + "maximum": 16777216, + "minimum": 65536, + "type": "integer" + }, + "maximumRounds": { + "maximum": 1024, + "minimum": 1, + "type": "integer" + }, + "maximumTotalProofNodes": { + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "maximumWorkUnits": { + "maximum": 16777216, + "minimum": 1, + "type": "integer" + }, + "v": { + "const": 1 + } + }, + "required": [ + "maximumDerivedTuples", + "maximumProofDepth", + "maximumProofNodes", + "maximumResultBytes", + "maximumRounds", + "maximumTotalProofNodes", + "maximumWorkUnits", + "v" + ], + "type": "object" + }, + "engineCode": { + "maxLength": 256, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "factProof": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "fact" + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "sources": { + "items": { + "$ref": "#/$defs/source" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "relation", + "sources", + "tuple", + "v" + ], + "type": "object" + }, + "proof": { + "oneOf": [ + { + "$ref": "#/$defs/factProof" + }, + { + "$ref": "#/$defs/derivedProof" + }, + { + "$ref": "#/$defs/truncatedProof" + } + ] + }, + "row": { + "additionalProperties": false, + "properties": { + "proofs": { + "items": { + "$ref": "#/$defs/proof" + }, + "maxItems": 64, + "type": "array" + }, + "proofsTruncated": { + "type": "boolean" + }, + "supportCount": { + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, + "values": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "proofs", + "proofsTruncated", + "supportCount", + "values", + "v" + ], + "type": "object" + }, + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "key": { + "maxLength": 512, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "recordSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "key", + "recordSha256", + "v" + ], + "type": "object" + }, + "stats": { + "additionalProperties": false, + "properties": { + "baseFacts": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "derivedFacts": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "proofNodes": { + "maximum": 65536, + "minimum": 0, + "type": "integer" + }, + "proofsTruncated": { + "type": "boolean" + }, + "queryMatches": { + "maximum": 262144, + "minimum": 0, + "type": "integer" + }, + "relations": { + "maximum": 4096, + "minimum": 0, + "type": "integer" + }, + "rounds": { + "maximum": 1024, + "minimum": 0, + "type": "integer" + }, + "truncated": { + "type": "boolean" + }, + "truncationReasons": { + "items": { + "enum": [ + "query-limit", + "result-bytes" + ] + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "v": { + "const": 1 + }, + "workUnits": { + "maximum": 16777216, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "baseFacts", + "derivedFacts", + "proofNodes", + "proofsTruncated", + "queryMatches", + "relations", + "rounds", + "truncated", + "truncationReasons", + "v", + "workUnits" + ], + "type": "object" + }, + "truncatedProof": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "truncated" + }, + "reason": { + "enum": [ + "cycle", + "depth", + "nodes" + ] + }, + "relation": { + "$ref": "#/$defs/safeCode" + }, + "tuple": { + "$ref": "#/$defs/tuple" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "reason", + "relation", + "tuple", + "v" + ], + "type": "object" + }, + "tuple": { + "items": { + "$ref": "#/$defs/atom" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + } + }, + "$id": "https://oh.computer/spec/v1/projection-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally checks canonical row and source order, atom byte limits, declared aggregate limits, proof depth and node totals, truncation consistency, engineSha256, evaluationSha256, projectionSha256, and resultSha256.", + "additionalProperties": false, + "properties": { + "authority": { + "const": "derived" + }, + "cache": { + "additionalProperties": false, + "properties": { + "strategy": { + "const": "full-rebuild" + }, + "v": { + "const": 1 + } + }, + "required": [ + "strategy", + "v" + ], + "type": "object" + }, + "engine": { + "$ref": "#/$defs/engineCode" + }, + "evaluation": { + "$ref": "#/$defs/evaluation" + }, + "identity": { + "$ref": "./projection-identity.schema.json" + }, + "resultSha256": { + "$ref": "#/$defs/sha256" + }, + "rows": { + "items": { + "$ref": "#/$defs/row" + }, + "maxItems": 65536, + "type": "array" + }, + "stats": { + "$ref": "#/$defs/stats" + }, + "v": { + "const": 1 + } + }, + "required": [ + "authority", + "cache", + "engine", + "evaluation", + "identity", + "resultSha256", + "rows", + "stats", + "v" + ], + "title": "Oh projection result V1", + "type": "object" +} diff --git a/spec/v1/projection-rule-pack.schema.json b/spec/v1/projection-rule-pack.schema.json new file mode 100644 index 0000000..4864012 --- /dev/null +++ b/spec/v1/projection-rule-pack.schema.json @@ -0,0 +1,182 @@ +{ + "$defs": { + "atom": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "maxLength": 16382, + "type": "string" + } + ] + }, + "constantTerm": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "constant" + }, + "v": { + "const": 1 + }, + "value": { + "$ref": "#/$defs/atom" + } + }, + "required": [ + "kind", + "v", + "value" + ], + "type": "object" + }, + "literal": { + "additionalProperties": false, + "properties": { + "relation": { + "$ref": "#/$defs/safeCode" + }, + "terms": { + "items": { + "$ref": "#/$defs/term" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + }, + "v": { + "const": 1 + } + }, + "required": [ + "relation", + "terms", + "v" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "body": { + "items": { + "$ref": "#/$defs/literal" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "head": { + "$ref": "#/$defs/literal" + }, + "ruleId": { + "$ref": "#/$defs/safeCode" + }, + "ruleSha256": { + "$ref": "#/$defs/sha256" + }, + "v": { + "const": 1 + } + }, + "required": [ + "body", + "head", + "ruleId", + "ruleSha256", + "v" + ], + "type": "object" + }, + "safeCode": { + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "term": { + "oneOf": [ + { + "$ref": "#/$defs/constantTerm" + }, + { + "$ref": "#/$defs/variableTerm" + } + ] + }, + "variableTerm": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "variable" + }, + "name": { + "$ref": "#/$defs/safeCode" + }, + "v": { + "const": 1 + } + }, + "required": [ + "kind", + "name", + "v" + ], + "type": "object" + } + }, + "$id": "https://oh.computer/spec/v1/projection-rule-pack.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Runtime conformance additionally measures each atom's exact 16 KiB canonical-JSON byte ceiling, requires safe head variables, canonical rule ordering, unique rule IDs, and recomputed rule and pack digests.", + "additionalProperties": false, + "properties": { + "rulePackId": { + "$ref": "#/$defs/safeCode" + }, + "rulePackRevision": { + "minimum": 1, + "type": "integer" + }, + "rulePackSha256": { + "$ref": "#/$defs/sha256" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule" + }, + "maxItems": 1024, + "minItems": 1, + "type": "array" + }, + "rulesSha256": { + "$ref": "#/$defs/sha256" + }, + "semantics": { + "const": "oh.projection.positive-datalog.v1" + }, + "v": { + "const": 1 + } + }, + "required": [ + "rulePackId", + "rulePackRevision", + "rulePackSha256", + "rules", + "rulesSha256", + "semantics", + "v" + ], + "title": "Oh projection rule pack V1", + "type": "object" +} diff --git a/spec/v1/projection.md b/spec/v1/projection.md index 6c3c183..fe32ab7 100644 --- a/spec/v1/projection.md +++ b/spec/v1/projection.md @@ -64,6 +64,9 @@ Rule packs are sorted by rule ID and content-addressed. A query declares an ordered `find` variable list, a nonempty positive body, and an output limit. Query results use set semantics and sort tuples by canonical JSON. Declaration, fact, and insertion order do not affect rule-pack identity or output bytes. +The projection identity also binds the selected engine and resolved evaluation +limits, so results created with different engines, proof budgets, or work +budgets cannot share a cache identity. ## Evaluation limits @@ -79,31 +82,70 @@ The implementation checks hard ceilings before or during work: | Body literals per rule | 64 | | Evaluation rounds | 1,024 | | Join matches per rule or query body | 262,144 | +| Tuple-unification work units per evaluation | 16,777,216 | | Returned query rows | 65,536 | | Proof depth | 128 | | Proof nodes per row | 4,096 | - -A caller may request smaller derived-tuple, round, proof-depth, and proof-node -bounds. Exceeding a work bound fails closed. A query's declared output limit -returns the first canonical tuples and reports `stats.truncated: true` when more -distinct answers exist. +| Proof nodes across returned rows | 65,536 | +| Canonical result bytes | 16 MiB | + +A caller may request smaller derived-tuple, round, proof-depth, proof-node, and +global tuple-unification work bounds. The global work counter spans every rule, +round, and the final query, including unsuccessful candidate matches. Exceeding +a work bound fails closed. Result construction additionally stops before the +aggregate proof-node or canonical-byte ceilings. A query's declared output +limit or the result-byte ceiling returns a canonical prefix, sets +`stats.truncated: true`, and lists `query-limit` or `result-bytes` in +`stats.truncationReasons`. ## Proofs -Each returned row carries one proof for each query-body match. A fact leaf names -its relation, tuple, and exact source record references. A derived node names -the rule ID and digest and recursively contains its premises. Depth, node, and -cycle guards emit an explicit `truncated` node. A proof establishes how the -bounded evaluator derived a tuple from the supplied bytes; it does not establish -that a proposition is true. +Each returned row carries one proof for each literal in one canonical supporting +query-body match. `supportCount` reports how many complete matches produced the +same projected value tuple; V1 deliberately does not serialize every alternate +witness. A fact leaf names its relation, tuple, and exact source record +references. A derived node names the rule ID and digest and recursively contains +its premises. Depth and cycle guards emit an explicit `truncated` node. If a +node or byte budget ends between sibling premises, the enclosing derived node +sets `premisesTruncated: true`; if it ends between query-body proofs, the row +sets `proofsTruncated: true`. +`stats.proofsTruncated` reports either form across all returned rows. A proof +establishes how the bounded evaluator derived a tuple from the supplied bytes; +it does not establish that a proposition is true. + +## Safe cached ingress + +Projection declarations and cache output are untrusted exchange data. The +`parseOhProjectionRulePackV1`, `parseOhProjectionQueryV1`, and +`parseOhProjectionIdentityV1` parsers reject unknown keys and invalid digest +preimages. `parseOhProjectionProofV1` additionally applies the public proof +depth, node, tuple, source, atom-byte, and aggregate-byte ceilings before +returning a proof tree. + +`parseOhProjectionResultV1` is the cache-ingress boundary. It verifies the +result digest; canonical row and source order; `supportCount`; proof-node, +work-unit, relation, match, round, and byte totals; every proof and result +truncation marker; and all declared evaluation ceilings. It also recomputes the +engine and evaluation digests and requires them to match the projection +identity. A valid SHA-256 string by itself is not enough to make an envelope +acceptable. Cache readers SHOULD pass the projection digest they requested as +the parser's second argument; an internally consistent envelope does not prove +that a cache returned the requested identity. + +The discovery manifest publishes machine-readable schemas for rule packs, +queries, identities, and result envelopes. JSON Schema describes the exchange +shape and static maxima. The runtime parsers remain normative for canonical +ordering, digest preimages, aggregate budgets, and cross-field consistency that +the schemas cannot express. ## Cache invalidation `projectionSha256` binds the current contract, snapshot, dataset, rule pack, -query, and positive-Datalog semantics. A cached result is reusable only when -that digest is unchanged. Any snapshot, dataset, rule-pack, or query change has -`kind: "full-rebuild"` and lists the changed identities. V1 does not claim -incremental deletion or cross-snapshot maintenance. +query, engine, resolved evaluation limits, and positive-Datalog semantics. A cached +result is reusable only when that digest is unchanged. Any snapshot, dataset, +rule-pack, query, engine, or evaluation-limit change has `kind: "full-rebuild"` and +lists the changed identities. V1 does not claim incremental deletion or +cross-snapshot maintenance. ## Optional Suss equivalence lane @@ -114,8 +156,10 @@ every complete relation to the Oh reference semantics. It returns only after exact set agreement. Suss's public evaluator does not expose an execution-budget hook. Before calling -it, the adapter computes a conservative finite-domain upper bound and refuses a -program it cannot prove will remain under the requested derived-tuple ceiling. -It then runs the bounded reference evaluator for equivalence and canonical proof -construction. This lane evaluates compatibility, not performance. Refusal does -not disable the built-in evaluator. +it, the adapter first runs the bounded reference evaluator, then computes a +conservative finite-domain upper bound on new tuples in rule-head relations and +refuses a program it cannot prove will remain under the requested derived-tuple +ceiling. It compares Suss's complete result to the reference materialization and +uses the reference witnesses for canonical proof construction. This lane +evaluates compatibility, not performance. Refusal does not disable the built-in +evaluator. diff --git a/spec/v1/store.md b/spec/v1/store.md index da7bc4c..85aa95e 100644 --- a/spec/v1/store.md +++ b/spec/v1/store.md @@ -69,6 +69,12 @@ Run it in a deployment or migration step with a short-lived schema credential. installed schema and contract before reading or creating a bound data space, so a runtime token does not need schema-change permission. +Runtime open verifies the exact installed table, index, and trigger set, not +only a schema marker. Every operation, binding, and purge receipt read parses +its canonical JSON and cross-checks each duplicated SQL column. Current reads +also prove contiguous operation coverage through the exact terminal head, +record provenance puts, record digests, and dependency materialization. + Its private implementation tables use the `oh_authority_` prefix: | Table | Role | @@ -84,13 +90,28 @@ Its private implementation tables use the `oh_authority_` prefix: | `oh_authority_purges` | Minimal whole-space purge receipts. | | `oh_authority_commit_guards` | Empty constraint table used to abort a stale transactional batch. | -A remote commit reads one exact snapshot, computes the ordinary V1 operation, -then uses one write batch guarded by the expected head. The final guard aborts -the complete transaction when compare-and-swap did not settle at the declared -operation. The adapter re-reads and verifies the persisted canonical operation -before returning success. +A normal remote commit takes three atomic provider round trips: an idempotency, +head, and purge preflight; one exact current-materialization read; and one +guarded write batch. The final write guard aborts the complete transaction when +compare-and-swap did not settle at the declared operation. Its write-batch +readback must reproduce both the canonical operation and persisted head before +the adapter returns success. + +Provider responses are bounded as part of the API. V1 accepts at most 64 +changes, 512 dependencies, and 512 KiB of canonical operation JSON per commit. +A feed returns at most seven operations plus one checked sentinel and refuses a +page whose conservative transport estimate exceeds 9,000,000 bytes. Historical +replay is limited to 16,384 operations, 4 MiB of canonical operation JSON, and +the same response estimate. Current snapshot and full-verification result sets +are independently transport-estimated and SQL-gated before rows are returned. +Sizing, rows, and the pinned head are read in the same transaction, so a +concurrent append cannot grow an unchecked response between preflight and read. Remote purge similarly inserts a receipt only for the expected working head, deletes every payload and materialization row under that receipt in the same write batch, and aborts if either the receipt or deletion is incomplete. A later open returns the stored purge receipt instead of recreating the space. +Operation-record deletion resolves ownership through the canonical operation; +the purge postcondition also rejects any global orphan or cross-space owner +mismatch. Purge receipts are immutable and intentionally retain only binding, +prior-head, and purge-event evidence. diff --git a/src/cli.ts b/src/cli.ts index a5f5a72..280f2d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,7 @@ import { Oh } from "./sdk"; import { OH_SQLITE_SCHEMA_VERSION } from "./sqlite/migrations"; import { createOhSyncBundleV1, parseOhSyncBundleV1 } from "./sync"; -export const OH_PACKAGE_VERSION = "0.1.1" as const; +export const OH_PACKAGE_VERSION = "0.2.0" as const; type ParsedArguments = { options: Map; positionals: string[] }; type ValidatedInvocation = Readonly<{ diff --git a/src/libsql.test.ts b/src/libsql.test.ts index ebe840f..512f4cf 100644 --- a/src/libsql.test.ts +++ b/src/libsql.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; import { Database, type SQLQueryBindings } from "bun:sqlite"; +import { canonicalJson } from "./canonical"; import { createKnowledgeGraphRecordV1 } from "./graph"; import { bootstrapOhLibSqlAuthorityV1, createOhLibSqlStoreAuthorityV1, + OH_LIBSQL_STORE_LIMITS_V1, type OhLibSqlClientV1, type OhLibSqlResultV1, type OhLibSqlStatementV1, @@ -13,12 +15,14 @@ import { OH_CANONICAL_STORE_PROFILE_V1, OH_WORKING_STORE_PROFILE_V1, OhConflictError, + OhIntegrityError, OhProfileError, OhPurgedSpaceError, } from "./store"; class SqliteCompatibleLibSqlClient implements OhLibSqlClientV1 { readonly database = new Database(":memory:", { strict: true }); + lastBatchResponseBytes = 0; #execute(statement: OhLibSqlStatementV1 | string): OhLibSqlResultV1 { const sql = typeof statement === "string" ? statement : statement.sql; @@ -40,8 +44,10 @@ class SqliteCompatibleLibSqlClient implements OhLibSqlClientV1 { statements: readonly OhLibSqlStatementV1[], _mode?: "deferred" | "read" | "write", ): Promise { - return this.database.transaction((items: readonly OhLibSqlStatementV1[]) => + const results = this.database.transaction((items: readonly OhLibSqlStatementV1[]) => items.map((statement) => this.#execute(statement)))(statements); + this.lastBatchResponseBytes = Buffer.byteLength(JSON.stringify(results), "utf8"); + return results; } close(): void { this.database.close(); } @@ -57,6 +63,21 @@ async function bootstrappedClient(): Promise { } describe("direct libSQL Oh authority", () => { + test("refuses to bless preexisting or drifted authority schema objects", async () => { + const malformed = new SqliteCompatibleLibSqlClient(); + malformed.database.exec("CREATE TABLE oh_authority_commit_guards(value TEXT)"); + await expect(bootstrapOhLibSqlAuthorityV1(malformed)).rejects.toThrow(OhIntegrityError); + malformed.close(); + + const drifted = await bootstrappedClient(); + drifted.database.exec(`CREATE TRIGGER unexpected_authority_trigger + BEFORE INSERT ON oh_authority_records BEGIN SELECT 1; END`); + await expect(createOhLibSqlStoreAuthorityV1(drifted, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:drift", spaceId: "drift", + })).rejects.toThrow(OhIntegrityError); + drifted.close(); + }); + test("does not bootstrap schema as a side effect of the runtime open", async () => { const client = new SqliteCompatibleLibSqlClient(); await expect(createOhLibSqlStoreAuthorityV1(client, { @@ -111,6 +132,8 @@ describe("direct libSQL Oh authority", () => { expect((await authority.store.snapshot({ head: { operationSha256: first.operationSha256, sequence: first.sequence } })).records) .toEqual([child, parent].sort((left, right) => left.key.localeCompare(right.key))); + expect(await authority.store.snapshot({ head: { operationSha256: null, sequence: 0 } })) + .toMatchObject({ head: { operationSha256: null, sequence: 0 }, records: [] }); expect((await authority.store.exportDependencyClosure({ roots: [child.key] })).records) .toEqual([child, parent].sort((left, right) => left.key.localeCompare(right.key))); expect(await authority.store.verify()).toMatchObject({ integrity: "verified", operations: 2, records: 3 }); @@ -145,6 +168,188 @@ describe("direct libSQL Oh authority", () => { await first.store.close(); await second.store.close(); client.close(); }); + test("rejects orphaned idempotency rows and duplicated binding-column drift", async () => { + const sourceClient = await bootstrappedClient(); + const source = await createOhLibSqlStoreAuthorityV1(sourceClient, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:remote-orphan", spaceId: "remote-orphan", + }); + const changes = [{ kind: "put" as const, record: entity("entity:orphan", "Orphan"), v: 1 as const }]; + const operation = await source.store.commit({ actorId: "agent.remote", changes, + expectedHead: await source.store.head(), operationId: "op_remote_orphan" }); + + const targetClient = await bootstrappedClient(); + const target = await createOhLibSqlStoreAuthorityV1(targetClient, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:remote-orphan", spaceId: "remote-orphan", + }); + targetClient.database.query(`INSERT INTO oh_authority_operations(operation_sha256, space_id, + sequence, operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, + operation_json, instant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + operation.operationSha256, operation.spaceId, operation.sequence, operation.operationId, + operation.parentOperationSha256, operation.graphRevisionSha256, operation.recordsSha256, + canonicalJson(operation), operation.instant, + ); + await expect(target.store.commit({ actorId: operation.actorId, changes, + expectedHead: await target.store.head(), operationId: operation.operationId })) + .rejects.toThrow(OhIntegrityError); + expect((await target.store.head()).sequence).toBe(0); + targetClient.database.query("UPDATE oh_authority_bindings SET realm_id = ? WHERE space_id = ?") + .run("realm:alien", "remote-orphan"); + await expect(createOhLibSqlStoreAuthorityV1(targetClient, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:remote-orphan", spaceId: "remote-orphan", + })).rejects.toThrow(OhIntegrityError); + await target.store.close(); await source.store.close(); targetClient.close(); sourceClient.close(); + }); + + test("refuses snapshots and commits when the current head drifts from its operation", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:head-drift", spaceId: "head-drift", + }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:one", "One"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_head_one" }); + client.database.query("UPDATE oh_authority_spaces SET graph_revision_sha256 = ? WHERE space_id = ?") + .run("f".repeat(64), "head-drift"); + const drifted = await authority.store.head(); + await expect(authority.store.snapshot()).rejects.toThrow(OhIntegrityError); + await expect(authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:two", "Two"), v: 1 }], expectedHead: drifted, + operationId: "op_head_two" })).rejects.toThrow(OhIntegrityError); + expect((await authority.store.head()).sequence).toBe(1); + await authority.store.close(); client.close(); + }); + + test("detects record-column and operation-record tampering before advancing authority", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:tamper", spaceId: "tamper", + }); + const record = entity("entity:tamper", "Tamper"); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", record, v: 1 }], + expectedHead: await authority.store.head(), operationId: "op_tamper" }); + const head = await authority.store.head(); + client.database.query(`UPDATE oh_authority_records SET record_sha256 = ? + WHERE space_id = ? AND record_key = ?`).run("f".repeat(64), "tamper", record.key); + await expect(authority.store.verify()).rejects.toThrow(OhIntegrityError); + await expect(authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "tombstone", + key: record.key, priorSha256: record.recordSha256, v: 1 }], expectedHead: head, + operationId: "op_after_tamper" })).rejects.toThrow(OhIntegrityError); + expect((await authority.store.head()).sequence).toBe(1); + client.database.query(`UPDATE oh_authority_records SET record_sha256 = ? + WHERE space_id = ? AND record_key = ?`).run(record.recordSha256, "tamper", record.key); + expect(() => client.database.query("DELETE FROM oh_authority_operation_records WHERE space_id = ?") + .run("tamper")).toThrow("require a purge receipt"); + client.database.exec("DROP TRIGGER oh_authority_operation_records_guard_delete"); + client.database.query("DELETE FROM oh_authority_operation_records WHERE space_id = ?").run("tamper"); + await expect(authority.store.verify()).rejects.toThrow(OhIntegrityError); + await authority.store.close(); client.close(); + }); + + test("refuses to extend noncanonical current-record provenance", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:provenance", spaceId: "provenance", + }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:first", "First"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_provenance_one" }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:second", "Second"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_provenance_two" }); + const row = client.database.query<{ operation_json: string }, []>(`SELECT operation_json + FROM oh_authority_operations WHERE operation_id = 'op_provenance_one'`).get(); + expect(row).not.toBeNull(); + const noncanonical = JSON.stringify(JSON.parse(row?.operation_json ?? "null"), null, 2); + expect(() => client.database.query(`UPDATE oh_authority_operations SET operation_json = ? + WHERE operation_id = 'op_provenance_one'`).run(noncanonical)).toThrow("immutable"); + client.database.exec("DROP TRIGGER oh_authority_operations_no_update"); + client.database.query(`UPDATE oh_authority_operations SET operation_json = ? + WHERE operation_id = 'op_provenance_one'`).run(noncanonical); + const head = await authority.store.head(); + await expect(authority.store.snapshot()).rejects.toThrow(OhIntegrityError); + await expect(authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:third", "Third"), v: 1 }], expectedHead: head, + operationId: "op_provenance_three" })).rejects.toThrow(OhIntegrityError); + expect((await authority.store.head()).sequence).toBe(2); + await authority.store.close(); client.close(); + }); + + test("never reports a remote feed that omits its tail or hides a bad sentinel", async () => { + const client = await bootstrappedClient(); + const populate = async (spaceId: string) => { + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: `realm:${spaceId}`, spaceId, + }); + for (let index = 1; index <= 3; index += 1) { + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity(`entity:${spaceId}-${index}`, `${spaceId} ${index}`), v: 1 }], + expectedHead: await authority.store.head(), operationId: `op_${spaceId}_${index}` }); + } + return authority; + }; + const tail = await populate("feed-tail"); + const sentinel = await populate("feed-sentinel"); + client.database.exec("DROP TRIGGER oh_authority_operations_guard_delete"); + client.database.query("DELETE FROM oh_authority_operations WHERE space_id = ? AND sequence = 3") + .run("feed-tail"); + await expect(tail.store.changesSince({ operationSha256: null, sequence: 0 }, { limit: 3 })) + .rejects.toThrow(OhIntegrityError); + client.database.query("DELETE FROM oh_authority_operations WHERE space_id = ? AND sequence = 2") + .run("feed-sentinel"); + await expect(sentinel.store.changesSince({ operationSha256: null, sequence: 0 }, { limit: 1 })) + .rejects.toThrow(OhIntegrityError); + await tail.store.close(); await sentinel.store.close(); client.close(); + }); + + test("enforces provider-safe operation, feed, and snapshot response bounds", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:provider-bounds", spaceId: "provider-bounds", + }); + await expect(authority.store.changesSince({ operationSha256: null, sequence: 0 }, { limit: 16 })) + .rejects.toThrow(RangeError); + const oversized = entity("entity:oversized", "x".repeat(600_000)); + await expect(authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: oversized, v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_oversized" })).rejects.toThrow("canonical byte bound"); + expect((await authority.store.head()).sequence).toBe(0); + + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:bounded", "Bounded"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_bounded" }); + client.database.query(`UPDATE oh_authority_records + SET record_json = json_object('blob', hex(zeroblob(2200000))) WHERE space_id = ?`) + .run("provider-bounds"); + await expect(authority.store.snapshot()).rejects.toThrow("provider-safe response bounds"); + await authority.store.close(); client.close(); + }); + + test("keeps escape-heavy feeds and accepted history below the libSQL response ceiling", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:response-ceiling", + spaceId: "response-ceiling", + }); + const escaped = "\\\"\n".repeat(50_000); + const heads = []; + for (let index = 1; index <= 14; index += 1) { + const operation = await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:response-ceiling", `${String(index).padStart(2, "0")}:${escaped}`), v: 1 }], + expectedHead: await authority.store.head(), operationId: `op_response_ceiling_${index}` }); + heads.push({ operationSha256: operation.operationSha256, sequence: operation.sequence }); + } + expect(OH_LIBSQL_STORE_LIMITS_V1.changeFeedLimit).toBe(7); + const page = await authority.store.changesSince({ operationSha256: null, sequence: 0 }); + expect(page.operations).toHaveLength(7); + expect(page.hasMore).toBe(true); + expect(client.lastBatchResponseBytes).toBeLessThan(10_000_000); + + const historical = await authority.store.snapshot({ head: heads[12]! }); + expect(historical.head.sequence).toBe(13); + expect(client.lastBatchResponseBytes).toBeLessThan(10_000_000); + await authority.store.close(); client.close(); + }); + test("rejects profile drift and permanently purges a working realm with a receipt", async () => { const client = await bootstrappedClient(); const authority = await createOhLibSqlStoreAuthorityV1(client, { @@ -174,6 +379,79 @@ describe("direct libSQL Oh authority", () => { client.close(); }); + test("rejects new cross-space operation records and cleans legacy mismatches by owner", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:owner-purge", spaceId: "owner-purge", + }); + const operation = await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:private", "Private"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_owner_private" }); + const insertMismatch = () => client.database.query(`INSERT INTO oh_authority_operation_records( + space_id, operation_sha256, ordinal, record_key, change_kind, record_sha256) + VALUES (?, ?, 1, ?, 'put', ?)`).run("alien-space", operation.operationSha256, + "entity:alien", "f".repeat(64)); + expect(insertMismatch).toThrow("no owning operation"); + client.database.exec("DROP TRIGGER oh_authority_operation_records_guard_insert"); + insertMismatch(); + await authority.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" }); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_operation_records`).get()?.count).toBe(0); + await authority.store.close(); client.close(); + }); + + test("cannot resurrect a space purged between open preflight and creation batch", async () => { + const client = await bootstrappedClient(); + const original = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:open-purge-race", spaceId: "open-purge-race", + }); + let interleaved = false; + const racingClient: OhLibSqlClientV1 = { + execute: async (statement) => { + const result = await client.execute(statement); + const sql = typeof statement === "string" ? statement : statement.sql; + const firstArgument = typeof statement === "string" ? undefined : statement.args?.[0]; + if (!interleaved && firstArgument === "open-purge-race" + && sql.includes("FROM oh_authority_purges WHERE space_id = ?")) { + interleaved = true; + await original.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" }); + } + return result; + }, + batch: async (statements, mode) => await client.batch(statements, mode), + }; + await expect(createOhLibSqlStoreAuthorityV1(racingClient, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:open-purge-race", spaceId: "open-purge-race", + })).rejects.toThrow(OhPurgedSpaceError); + expect(interleaved).toBe(true); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_spaces WHERE space_id = 'open-purge-race'`).get()?.count).toBe(0); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_purges WHERE space_id = 'open-purge-race'`).get()?.count).toBe(1); + client.close(); + }); + + test("rolls purge back when any private payload row resists deletion", async () => { + const client = await bootstrappedClient(); + const authority = await createOhLibSqlStoreAuthorityV1(client, { + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:blocked-purge", spaceId: "blocked-purge", + }); + await authority.store.commit({ actorId: "agent.remote", changes: [{ kind: "put", + record: entity("entity:private", "Private"), v: 1 }], expectedHead: await authority.store.head(), + operationId: "op_blocked_private" }); + client.database.exec(`CREATE TRIGGER block_record_delete BEFORE DELETE ON oh_authority_records + BEGIN SELECT RAISE(IGNORE); END`); + await expect(authority.host.purgeWorkingSpace({ purgedAt: "2026-08-29T13:00:00.000Z" })) + .rejects.toThrow(); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_purges WHERE space_id = 'blocked-purge'`).get()?.count).toBe(0); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_records WHERE space_id = 'blocked-purge'`).get()?.count).toBe(1); + expect(client.database.query<{ count: number }, []>(`SELECT count(*) AS count + FROM oh_authority_spaces WHERE space_id = 'blocked-purge'`).get()?.count).toBe(1); + await authority.store.close(); client.close(); + }); + test("does not grant canonical stores a purge path", async () => { const client = await bootstrappedClient(); const authority = await createOhLibSqlStoreAuthorityV1(client, { diff --git a/src/libsql.ts b/src/libsql.ts index f3bd60e..32e9b31 100644 --- a/src/libsql.ts +++ b/src/libsql.ts @@ -4,10 +4,12 @@ import { canonicalSha256, parseSha256Hex, safeCode, + utf8ByteLength, type Sha256Hex, } from "./canonical"; import { OH_CONTRACT_MANIFEST_V1 } from "./contract"; -import { canonicalKnowledgeGraphChangesV1, parseKnowledgeGraphRecordV1, +import { canonicalKnowledgeGraphChangesV1, knowledgeGraphRecordRefV1, OH_GRAPH_LIMITS_V1, + parseKnowledgeGraphRecordV1, type KnowledgeGraphRecordV1 } from "./graph"; import { parseOhOperationV1, type OhOperationV1 } from "./operation"; import { @@ -65,9 +67,50 @@ export type OhLibSqlStoreAuthorityOptionsV1 = Readonly<{ spaceId?: string; }>; +export const OH_LIBSQL_STORE_LIMITS_V1 = Object.freeze({ + changesPerCommit: 64, + changeFeedLimit: 7, + dependenciesPerCommit: 512, + historyBytes: 4 * 1024 * 1024, + historyOperations: 16_384, + operationBytes: 512 * 1024, + providerResponseBytes: 9_000_000, + snapshotComponentBytes: 6 * 1024 * 1024, +}); + const AUTHORITY_SCHEMA_NAME = "oh.libsql-authority.v1"; const AUTHORITY_SCHEMA_VERSION = 1; const EMPTY_RECORDS_SHA256 = canonicalSha256([]); +const PURGE_ROW_SELECT = `SELECT space_id, binding_sha256, prior_operation_sha256, + prior_sequence, purged_at, receipt_sha256, receipt_json + FROM oh_authority_purges WHERE space_id = ?`; +const BINDING_ROW_SELECT = `SELECT space_id, realm_id, profile_id, profile_kind, + profile_sha256, binding_sha256, binding_json FROM oh_authority_bindings WHERE space_id = ?`; +const OPERATION_ROW_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +// libSQL serializes text again in its JSON response. Twice the UTF-8 text plus all +// duplicated columns and a fixed row reserve is a conservative upper bound for +// canonical JSON, whose own escapes can only be escaped once more by transport. +const OPERATION_RESPONSE_BYTES = `2 * length(CAST(operation.operation_json AS BLOB)) + + 2 * (length(operation.operation_sha256) + length(operation.space_id) + + length(operation.operation_id) + coalesce(length(operation.parent_operation_sha256), 0) + + length(operation.graph_revision_sha256) + length(operation.records_sha256) + + length(operation.instant)) + 512`; +const RECORD_RESPONSE_BYTES = `2 * length(CAST(record.record_json AS BLOB)) + + 2 * (length(record.record_key) + length(record.kind) + length(record.record_sha256) + + length(record.operation_sha256)) + 384`; +const DEPENDENCY_RESPONSE_BYTES = `2 * (length(dependency.record_key) + + length(dependency.dependency_key)) + 192`; +const OPERATION_RECORD_RESPONSE_BYTES = `2 * (length(materialized.space_id) + + length(materialized.operation_sha256) + length(materialized.record_key) + + length(materialized.change_kind) + length(materialized.record_sha256)) + 320`; + +const AUTHORITY_SCHEMA_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS oh_authority_schemas ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + schema_sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL +) STRICT`; const AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ `CREATE TABLE IF NOT EXISTS oh_authority_contracts ( @@ -101,6 +144,7 @@ const AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ UNIQUE(space_id, operation_id) ) STRICT`, `CREATE TABLE IF NOT EXISTS oh_authority_operation_records ( + space_id TEXT NOT NULL, operation_sha256 TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal >= 0), record_key TEXT NOT NULL, @@ -150,9 +194,64 @@ const AUTHORITY_SCHEMA_STATEMENTS = Object.freeze([ "CREATE INDEX IF NOT EXISTS oh_authority_operations_space_sequence ON oh_authority_operations(space_id, sequence)", "CREATE INDEX IF NOT EXISTS oh_authority_records_space_kind ON oh_authority_records(space_id, kind, record_key)", "CREATE INDEX IF NOT EXISTS oh_authority_dependencies_dependency ON oh_authority_dependencies(space_id, dependency_key)", + `CREATE TRIGGER IF NOT EXISTS oh_authority_operations_no_update + BEFORE UPDATE ON oh_authority_operations + BEGIN SELECT RAISE(ABORT, 'Oh authority operations are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operations_guard_delete + BEFORE DELETE ON oh_authority_operations + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = OLD.space_id) + BEGIN SELECT RAISE(ABORT, 'Oh authority operations require a purge receipt'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_no_update + BEFORE UPDATE ON oh_authority_operation_records + BEGIN SELECT RAISE(ABORT, 'Oh authority operation records are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_guard_insert + BEFORE INSERT ON oh_authority_operation_records + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_operations + WHERE operation_sha256 = NEW.operation_sha256 AND space_id = NEW.space_id) + BEGIN SELECT RAISE(ABORT, 'Oh authority operation record has no owning operation'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_operation_records_guard_delete + BEFORE DELETE ON oh_authority_operation_records + WHEN NOT EXISTS (SELECT 1 FROM oh_authority_operations AS operation + JOIN oh_authority_purges AS purge ON purge.space_id = operation.space_id + WHERE operation.operation_sha256 = OLD.operation_sha256) + BEGIN SELECT RAISE(ABORT, 'Oh authority operation records require a purge receipt'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_purges_immutable_update + BEFORE UPDATE ON oh_authority_purges + BEGIN SELECT RAISE(ABORT, 'Oh authority purge receipts are immutable'); END`, + `CREATE TRIGGER IF NOT EXISTS oh_authority_purges_immutable_delete + BEFORE DELETE ON oh_authority_purges + BEGIN SELECT RAISE(ABORT, 'Oh authority purge receipts are immutable'); END`, ]); -const AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_STATEMENTS); +function normalizedSchemaSql(sql: string): string { + return sql.replace(/\bIF\s+NOT\s+EXISTS\b/giu, "").replace(/\s+/gu, " ").trim(); +} + +function expectedSchemaObject(statement: string): Readonly<{ + name: string; + sql: string; + tableName: string; + type: "index" | "table" | "trigger"; +}> { + const match = /^CREATE\s+(TABLE|INDEX|TRIGGER)(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-z0-9_]+)/iu.exec(statement.trim()); + if (match === null) throw new Error("Invalid compiled authority schema statement."); + const declaredType = match[1]?.toLowerCase(); + const type = declaredType === "index" ? "index" as const + : declaredType === "trigger" ? "trigger" as const : "table" as const; + const name = match[2] as string; + const tableMatch = type === "index" || type === "trigger" ? /\bON\s+([a-z0-9_]+)/iu.exec(statement) : null; + const tableName = type === "table" ? name : tableMatch?.[1]; + if (tableName === undefined) throw new Error("Invalid compiled authority index statement."); + return { name, sql: normalizedSchemaSql(statement), tableName, type }; +} + +const AUTHORITY_SCHEMA_OBJECTS = Object.freeze( + [AUTHORITY_SCHEMA_TABLE_STATEMENT, ...AUTHORITY_SCHEMA_STATEMENTS] + .map(expectedSchemaObject) + .sort((left, right) => canonicalJson([left.type, left.name]) + .localeCompare(canonicalJson([right.type, right.name]))), +); +const AUTHORITY_SCHEMA_SHA256 = canonicalSha256(AUTHORITY_SCHEMA_OBJECTS); function rowValue( row: Readonly> | readonly unknown[], @@ -163,8 +262,12 @@ function rowValue( } function integer(value: unknown): number | null { - const parsed = typeof value === "bigint" ? Number(value) : Number(value); - return Number.isSafeInteger(parsed) ? parsed : null; + if (typeof value === "number") return Number.isSafeInteger(value) ? value : null; + if (typeof value === "bigint") { + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; + } + return null; } function normalizeLimit(value: number | undefined, fallback = 100, maximum = 1000): number { @@ -186,6 +289,74 @@ function parseOperationJson(value: unknown): OhOperationV1 { return operation; } +function parseOperationRow( + row: Readonly> | readonly unknown[], + expected: Readonly<{ operationId?: string; operationSha256?: string; spaceId?: string }> = {}, +): OhOperationV1 { + const operation = parseOperationJson(rowValue(row, "operation_json", 7)); + if (rowValue(row, "operation_sha256", 0) !== operation.operationSha256 + || rowValue(row, "space_id", 1) !== operation.spaceId + || integer(rowValue(row, "sequence", 2)) !== operation.sequence + || rowValue(row, "operation_id", 3) !== operation.operationId + || rowValue(row, "parent_operation_sha256", 4) !== operation.parentOperationSha256 + || rowValue(row, "graph_revision_sha256", 5) !== operation.graphRevisionSha256 + || rowValue(row, "records_sha256", 6) !== operation.recordsSha256 + || rowValue(row, "instant", 8) !== operation.instant + || (expected.spaceId !== undefined && operation.spaceId !== expected.spaceId) + || (expected.operationId !== undefined && operation.operationId !== expected.operationId) + || (expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256)) { + throw new OhIntegrityError("Remote operation columns do not match their canonical envelope."); + } + return operation; +} + +function parseBindingRow( + row: Readonly> | readonly unknown[], + expectedSpaceId: string, +): OhStoreBindingV1 { + const json = rowValue(row, "binding_json", 6); + if (typeof json !== "string") throw new OhIntegrityError("A remote store binding is not JSON text."); + let value: unknown; + try { value = JSON.parse(json); } catch { throw new OhIntegrityError("A remote store binding is not JSON."); } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== json || binding.spaceId !== expectedSpaceId + || rowValue(row, "space_id", 0) !== binding.spaceId + || rowValue(row, "realm_id", 1) !== binding.realmId + || rowValue(row, "profile_id", 2) !== binding.profile.profileId + || rowValue(row, "profile_kind", 3) !== binding.profile.profileKind + || rowValue(row, "profile_sha256", 4) !== binding.profile.profileSha256 + || rowValue(row, "binding_sha256", 5) !== binding.bindingSha256) { + throw new OhIntegrityError("Remote binding columns do not match their canonical envelope."); + } + return binding; +} + +function parsePurgeReceiptRow( + row: Readonly> | readonly unknown[], + expectedSpaceId: string, + expectedBindingSha256?: Sha256Hex, +): OhSpacePurgeReceiptV1 { + const json = rowValue(row, "receipt_json", 6); + if (typeof json !== "string") throw new OhIntegrityError("A remote purge receipt is invalid."); + let value: unknown; + try { value = JSON.parse(json); } catch { throw new OhIntegrityError("A remote purge receipt is invalid."); } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== json) { + throw new OhIntegrityError("A remote purge receipt is invalid."); + } + if (receipt.spaceId !== expectedSpaceId + || (expectedBindingSha256 !== undefined && receipt.bindingSha256 !== expectedBindingSha256) + || rowValue(row, "space_id", 0) !== receipt.spaceId + || rowValue(row, "binding_sha256", 1) !== receipt.bindingSha256 + || rowValue(row, "prior_operation_sha256", 2) !== receipt.priorHead.operationSha256 + || integer(rowValue(row, "prior_sequence", 3)) !== receipt.priorHead.sequence + || rowValue(row, "purged_at", 4) !== receipt.purgedAt + || rowValue(row, "receipt_sha256", 5) !== receipt.receiptSha256) { + throw new OhIntegrityError("Remote purge columns do not match their canonical receipt."); + } + return receipt; +} + function parseHeadRow(row: Readonly> | readonly unknown[]): OhHeadV1 { const generation = integer(rowValue(row, "generation", 0)); const graphValue = rowValue(row, "graph_revision_sha256", 1); @@ -211,6 +382,27 @@ async function queryOne( return (await client.execute(statement)).rows[0] ?? null; } +async function verifyAuthoritySchemaObjects(client: OhLibSqlClientV1): Promise { + const rows = (await client.execute({ sql: `SELECT type, name, tbl_name, sql FROM sqlite_schema + WHERE sql IS NOT NULL AND (name = 'oh_authority_schemas' OR name GLOB 'oh_authority_*' + OR tbl_name GLOB 'oh_authority_*') + ORDER BY type, name` })).rows; + const actual = rows.map((row) => { + const type = rowValue(row, "type", 0); + const name = rowValue(row, "name", 1); + const tableName = rowValue(row, "tbl_name", 2); + const sql = rowValue(row, "sql", 3); + if ((type !== "table" && type !== "index" && type !== "trigger") || typeof name !== "string" + || typeof tableName !== "string" || typeof sql !== "string") { + throw new OhIntegrityError("The installed libSQL authority has an invalid schema object."); + } + return { name, sql: normalizedSchemaSql(sql), tableName, type }; + }); + if (canonicalJson(actual) !== canonicalJson(AUTHORITY_SCHEMA_OBJECTS)) { + throw new OhIntegrityError("The installed libSQL authority objects differ from this runtime."); + } +} + async function verifyAuthoritySchema(client: OhLibSqlClientV1): Promise { const installed = await queryOne(client, { sql: `SELECT name, schema_sha256 FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); @@ -224,25 +416,25 @@ async function verifyAuthoritySchema(client: OhLibSqlClientV1): Promise { || rowValue(contract, "manifest_json", 1) !== canonicalJson(OH_CONTRACT_MANIFEST_V1)) { throw new OhIntegrityError("The remote authority contract differs from this runtime."); } + await verifyAuthoritySchemaObjects(client); } /** One-time schema operation for a client authorized to create authority tables. */ export async function bootstrapOhLibSqlAuthorityV1( client: OhLibSqlClientV1, ): Promise> { - await client.execute(`CREATE TABLE IF NOT EXISTS oh_authority_schemas ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - schema_sha256 TEXT NOT NULL, - applied_at TEXT NOT NULL - ) STRICT`); - const applied = await queryOne(client, { sql: `SELECT name, schema_sha256 - FROM oh_authority_schemas WHERE version = ?`, args: [AUTHORITY_SCHEMA_VERSION] }); - if (applied !== null && (rowValue(applied, "name", 0) !== AUTHORITY_SCHEMA_NAME - || rowValue(applied, "schema_sha256", 1) !== AUTHORITY_SCHEMA_SHA256)) { - throw new OhIntegrityError("The installed libSQL authority schema differs from this runtime."); + const existingObjects = (await client.execute({ sql: `SELECT name FROM sqlite_schema + WHERE sql IS NOT NULL AND (name = 'oh_authority_schemas' OR name GLOB 'oh_authority_*' + OR tbl_name GLOB 'oh_authority_*')` })).rows; + if (existingObjects.length > 0) { + if (!existingObjects.some((row) => rowValue(row, "name", 0) === "oh_authority_schemas")) { + throw new OhIntegrityError("Refusing to bootstrap over preexisting Oh authority objects."); + } + await verifyAuthoritySchema(client); + return { schemaSha256: AUTHORITY_SCHEMA_SHA256, schemaVersion: 1, v: 1 }; } - const setup: OhLibSqlStatementV1[] = AUTHORITY_SCHEMA_STATEMENTS.map((sql) => ({ sql })); + const setup: OhLibSqlStatementV1[] = [AUTHORITY_SCHEMA_TABLE_STATEMENT, ...AUTHORITY_SCHEMA_STATEMENTS] + .map((sql) => ({ sql })); setup.push({ sql: `INSERT INTO oh_authority_schemas(version, name, schema_sha256, applied_at) VALUES (?, ?, ?, ?) ON CONFLICT(version) DO NOTHING`, args: [AUTHORITY_SCHEMA_VERSION, AUTHORITY_SCHEMA_NAME, AUTHORITY_SCHEMA_SHA256, canonicalNow()] }); @@ -258,30 +450,54 @@ async function initializeSpace( client: OhLibSqlClientV1, binding: OhStoreBindingV1, ): Promise { - const purged = await queryOne(client, { sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + const purged = await queryOne(client, { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }); - if (purged !== null) { - const json = rowValue(purged, "receipt_json", 0); - if (typeof json !== "string") throw new OhIntegrityError("A remote purge receipt is invalid."); - const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); - if (receipt === null || canonicalJson(receipt) !== json) throw new OhIntegrityError("A remote purge receipt is invalid."); - throw new OhPurgedSpaceError(receipt); - } + if (purged !== null) throw new OhPurgedSpaceError(parsePurgeReceiptRow( + purged, binding.spaceId, binding.bindingSha256)); const now = canonicalNow(); - await client.batch([ + try { await client.batch([ { sql: `INSERT INTO oh_authority_spaces(space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, records_sha256, sequence, created_at, updated_at) - VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`, - args: [binding.spaceId, OH_CONTRACT_MANIFEST_V1.contractId, EMPTY_RECORDS_SHA256, now, now] }, + SELECT ?, ?, 0, NULL, NULL, ?, 0, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, + args: [binding.spaceId, OH_CONTRACT_MANIFEST_V1.contractId, EMPTY_RECORDS_SHA256, now, now, + binding.spaceId] }, { sql: `INSERT INTO oh_authority_bindings(space_id, realm_id, profile_id, profile_kind, profile_sha256, binding_sha256, binding_json, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`, + SELECT ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + AND EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, args: [binding.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind, - binding.profile.profileSha256, binding.bindingSha256, canonicalJson(binding), now] }, - ], "write"); - const persisted = await queryOne(client, { sql: "SELECT binding_json FROM oh_authority_bindings WHERE space_id = ?", - args: [binding.spaceId] }); - if (persisted === null || rowValue(persisted, "binding_json", 0) !== canonicalJson(binding)) { + binding.profile.profileSha256, binding.bindingSha256, canonicalJson(binding), now, + binding.spaceId, binding.spaceId] }, + { sql: `INSERT INTO oh_authority_commit_guards(value) + SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ?) + OR NOT EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR NOT EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ? AND binding_sha256 = ?)`, + args: [binding.spaceId, binding.spaceId, binding.spaceId, binding.bindingSha256] }, + ], "write"); } catch (error) { + const raced = await queryOne(client, { sql: PURGE_ROW_SELECT, + args: [binding.spaceId] }); + if (raced !== null) throw new OhPurgedSpaceError(parsePurgeReceiptRow( + raced, binding.spaceId, binding.bindingSha256)); + const persisted = await queryOne(client, { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }); + if (persisted !== null + && canonicalJson(parseBindingRow(persisted, binding.spaceId)) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is already bound to a different realm or profile."); + } + throw error; + } + const persisted = await queryOne(client, { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }); + if (persisted === null) { + const raced = await queryOne(client, { sql: PURGE_ROW_SELECT, + args: [binding.spaceId] }); + if (raced !== null) throw new OhPurgedSpaceError(parsePurgeReceiptRow( + raced, binding.spaceId, binding.bindingSha256)); + throw new OhIntegrityError("The remote space has no persisted binding after initialization."); + } + if (canonicalJson(parseBindingRow(persisted, binding.spaceId)) !== canonicalJson(binding)) { throw new OhProfileError("The remote space is already bound to a different realm or profile."); } } @@ -318,25 +534,22 @@ class OhLibSqlStoreV1 implements OhStoreV1 { } async #readPurge(): Promise { - const row = await queryOne(this.#client, { sql: "SELECT receipt_json FROM oh_authority_purges WHERE space_id = ?", + const row = await queryOne(this.#client, { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }); if (row === null) return null; - const json = rowValue(row, "receipt_json", 0); - if (typeof json !== "string") throw new OhIntegrityError("A remote purge receipt is invalid."); - const receipt = parseOhSpacePurgeReceiptV1(JSON.parse(json)); - if (receipt === null || canonicalJson(receipt) !== json) throw new OhIntegrityError("A remote purge receipt is invalid."); - return receipt; + return parsePurgeReceiptRow(row, this.binding.spaceId, this.binding.bindingSha256); } async #headAt(reference: OhHeadRefV1): Promise { const parsed = parseOhHeadRefV1(reference); if (parsed === null) throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); - const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + const row = await queryOne(this.#client, { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, parsed.sequence] }); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - const operation = parseOperationJson(rowValue(row, "operation_json", 0)); - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (operation.spaceId !== this.binding.spaceId || operation.sequence !== parsed.sequence + || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256, @@ -344,6 +557,159 @@ class OhLibSqlStoreV1 implements OhStoreV1 { sequence: operation.sequence, v: 1 }; } + async #currentMaterializedSnapshot(expectedHead: OhHeadV1, maximumRecords: number): Promise { + const provenancePredicate = `operation.space_id = ? AND (operation.operation_sha256 IS ? + OR operation.operation_sha256 IN (SELECT record.operation_sha256 + FROM oh_authority_records AS record WHERE record.space_id = ?))`; + const results = await this.#client.batch([ + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: `SELECT + (SELECT count(*) FROM (SELECT DISTINCT operation.operation_sha256 + FROM oh_authority_operations AS operation WHERE ${provenancePredicate})) AS provenance_count, + (SELECT coalesce(sum(bytes), 0) FROM (SELECT DISTINCT operation.operation_sha256, + ${OPERATION_RESPONSE_BYTES} AS bytes FROM oh_authority_operations AS operation + WHERE ${provenancePredicate})) AS provenance_bytes, + (SELECT count(*) FROM oh_authority_records WHERE space_id = ?) AS record_count, + (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) FROM oh_authority_records AS record + WHERE record.space_id = ?) AS record_bytes, + (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ?) AS dependency_bytes`, + args: [this.binding.spaceId, expectedHead.operationSha256, this.binding.spaceId, + this.binding.spaceId, expectedHead.operationSha256, this.binding.spaceId, + this.binding.spaceId, this.binding.spaceId, this.binding.spaceId] }, + { sql: `SELECT record_key, kind, record_sha256, record_json, operation_sha256, sequence + FROM oh_authority_records AS record WHERE record.space_id = ? + AND (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) <= ? ORDER BY record_key`, args: [this.binding.spaceId, + this.binding.spaceId, OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT record_key, dependency_key FROM oh_authority_dependencies + AS dependency WHERE dependency.space_id = ? + AND (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency WHERE dependency.space_id = ?) <= ? + ORDER BY record_key, dependency_key`, + args: [this.binding.spaceId, this.binding.spaceId, OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} + FROM oh_authority_operations AS operation + WHERE ${provenancePredicate} + AND (SELECT coalesce(sum(bytes), 0) FROM (SELECT DISTINCT candidate.operation_sha256, + ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND (candidate.operation_sha256 IS ? + OR candidate.operation_sha256 IN (SELECT record.operation_sha256 + FROM oh_authority_records AS record WHERE record.space_id = ?)))) <= ? + ORDER BY operation.sequence`, + args: [this.binding.spaceId, expectedHead.operationSha256, this.binding.spaceId, + this.binding.spaceId, expectedHead.operationSha256, this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] }, + ], "read"); + if (results.length !== 6) throw new OhIntegrityError("The remote authority returned an incomplete snapshot batch."); + const [headResult, sizeResult, recordResult, dependencyResult, provenanceResult, historyResult] = results as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, + OhLibSqlResultV1, OhLibSqlResultV1]; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purge = await this.#readPurge(); + if (purge !== null) { this.#purged = purge; throw new OhPurgedSpaceError(purge); } + throw new OhIntegrityError("The remote Oh space disappeared while reading its snapshot."); + } + const head = parseHeadRow(headRow); + if (canonicalJson(head) !== canonicalJson(expectedHead)) { + throw new OhConflictError("The remote space head changed while reading its current snapshot."); + } + const size = sizeResult.rows[0]; + const provenanceOperations = size === undefined ? null : integer(rowValue(size, "provenance_count", 0)); + const provenanceBytes = size === undefined ? null : integer(rowValue(size, "provenance_bytes", 1)); + const recordCount = size === undefined ? null : integer(rowValue(size, "record_count", 2)); + const recordBytes = size === undefined ? null : integer(rowValue(size, "record_bytes", 3)); + const dependencyBytes = size === undefined ? null : integer(rowValue(size, "dependency_bytes", 4)); + if (provenanceOperations === null || provenanceBytes === null || recordCount === null + || recordBytes === null || dependencyBytes === null + || provenanceOperations > OH_LIBSQL_STORE_LIMITS_V1.historyOperations + || provenanceBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + || recordBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + || dependencyBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes) { + throw new RangeError("The current libSQL materialization exceeds its provider-safe response bounds."); + } + if (recordResult.rows.length !== recordCount || provenanceResult.rows.length !== provenanceOperations) { + throw new OhIntegrityError("The provider-safe snapshot queries omitted bounded authority rows."); + } + const history = historyResult.rows[0]; + const operationCount = history === undefined ? null : integer(rowValue(history, "count", 0)); + const minimumValue = history === undefined ? undefined : rowValue(history, "minimum", 1); + const maximumValue = history === undefined ? undefined : rowValue(history, "maximum", 2); + const minimumSequence = history === undefined ? null : integer(rowValue(history, "minimum", 1)); + const maximumSequence = history === undefined ? null : integer(rowValue(history, "maximum", 2)); + if (operationCount !== head.sequence + || (head.sequence === 0 && (minimumValue !== null || maximumValue !== null)) + || (head.sequence > 0 && (minimumSequence !== 1 || maximumSequence !== head.sequence))) { + throw new OhIntegrityError("The remote operation history does not exactly cover its current head."); + } + if (recordResult.rows.length > maximumRecords) { + throw new RangeError("The remote graph exceeds the requested record snapshot bound."); + } + const provenanceBySha256 = new Map(); + for (const row of provenanceResult.rows) { + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (provenanceBySha256.has(operation.operationSha256)) { + throw new OhIntegrityError("A current materialization provenance operation is invalid."); + } + provenanceBySha256.set(operation.operationSha256, operation); + } + if (provenanceBySha256.size !== provenanceOperations + || (head.operationSha256 !== null && !provenanceBySha256.has(head.operationSha256))) { + throw new OhIntegrityError("The current materialization omitted required provenance operations."); + } + if (head.sequence > 0) { + const terminal = head.operationSha256 === null ? undefined : provenanceBySha256.get(head.operationSha256); + if (terminal === undefined || terminal.sequence !== head.sequence + || terminal.graphRevisionSha256 !== head.graphRevisionSha256 + || terminal.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The remote space head differs from its terminal canonical operation."); + } + } + const materialized = recordResult.rows.map((row) => { + const json = rowValue(row, "record_json", 3); + if (typeof json !== "string") throw new OhIntegrityError("A materialized remote record is not JSON text."); + let value: unknown; + try { value = JSON.parse(json); } catch { throw new OhIntegrityError("A materialized remote record is invalid."); } + const record = parseKnowledgeGraphRecordV1(value); + const operationSha256 = parseSha256Hex(rowValue(row, "operation_sha256", 4)); + const sequence = integer(rowValue(row, "sequence", 5)); + if (record === null || canonicalJson(record) !== json || operationSha256 === null + || sequence === null || sequence < 1 || sequence > head.sequence + || rowValue(row, "record_key", 0) !== record.key + || rowValue(row, "kind", 1) !== record.kind + || rowValue(row, "record_sha256", 2) !== record.recordSha256) { + throw new OhIntegrityError("A materialized remote record is invalid."); + } + const provenance = provenanceBySha256.get(operationSha256); + if (provenance === undefined || provenance.sequence !== sequence + || !provenance.changes.some((change) => change.kind === "put" + && canonicalJson(change.record) === json)) { + throw new OhIntegrityError("A materialized remote record has no exact canonical provenance put."); + } + return { record, sequence }; + }); + const records = materialized.map(({ record }) => record); + if (canonicalSha256(records.map(knowledgeGraphRecordRefV1)) !== head.recordsSha256) { + throw new OhIntegrityError("Materialized remote records do not reproduce the current head."); + } + const dependencyRows = dependencyResult.rows.map((row) => ({ + dependency_key: rowValue(row, "dependency_key", 1), + record_key: rowValue(row, "record_key", 0), + })); + const expectedDependencies = records.flatMap((record) => + record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key }))); + if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { + throw new OhIntegrityError("Materialized remote dependencies do not match their record envelopes."); + } + return { head, records, v: 1 }; + } + async snapshot(options: Readonly<{ head?: OhHeadRefV1; maximumRecords?: number; @@ -352,10 +718,71 @@ class OhLibSqlStoreV1 implements OhStoreV1 { const current = await this.head(); const target = options.head === undefined ? current : await this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); - const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations - WHERE space_id = ? AND sequence <= ? ORDER BY sequence`, args: [this.binding.spaceId, target.sequence] })).rows; - const operations = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); - const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, options.maximumRecords); + const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot; + if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1 + || maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) { + throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`); + } + if (target.operationSha256 === current.operationSha256) { + return await this.#currentMaterializedSnapshot(current, maximumRecords); + } + if (target.sequence > OH_LIBSQL_STORE_LIMITS_V1.historyOperations) { + throw new RangeError("The requested libSQL history exceeds its operation replay bound."); + } + const historyResults = await this.#client.batch([ + { sql: `SELECT count(*) AS count, min(operation.sequence) AS minimum, + max(operation.sequence) AS maximum, + coalesce(sum(length(CAST(operation.operation_json AS BLOB))), 0) AS canonical_bytes, + coalesce(sum(${OPERATION_RESPONSE_BYTES}), 0) AS response_bytes + FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ?`, + args: [this.binding.spaceId, target.sequence] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(length(CAST(candidate.operation_json AS BLOB))), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + AND (SELECT coalesce(sum(${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")}), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + ORDER BY operation.sequence`, args: [this.binding.spaceId, target.sequence, + this.binding.spaceId, target.sequence, OH_LIBSQL_STORE_LIMITS_V1.historyBytes, + this.binding.spaceId, target.sequence, OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }, + ], "read"); + if (historyResults.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete history batch."); + } + const [historySizeResult, historyRowResult, purgeResult] = historyResults as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1]; + const historySizeRow = historySizeResult.rows[0]; + const historyBytes = historySizeRow === undefined ? null + : integer(rowValue(historySizeRow, "canonical_bytes", 3)); + const responseBytes = historySizeRow === undefined ? null + : integer(rowValue(historySizeRow, "response_bytes", 4)); + if (historyBytes === null || historyBytes > OH_LIBSQL_STORE_LIMITS_V1.historyBytes + || responseBytes === null || responseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes) { + throw new RangeError("The requested libSQL history exceeds its provider-safe replay bounds."); + } + const historyCount = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "count", 0)); + const minimumSequence = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "minimum", 1)); + const maximumSequence = historySizeRow === undefined ? null : integer(rowValue(historySizeRow, "maximum", 2)); + if (historyCount !== target.sequence + || (target.sequence === 0 && (rowValue(historySizeRow!, "minimum", 1) !== null + || rowValue(historySizeRow!, "maximum", 2) !== null)) + || (target.sequence > 0 && (minimumSequence !== 1 || maximumSequence !== target.sequence)) + || historyRowResult.rows.length !== historyCount) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote operation history does not exactly cover the requested head."); + } + const operations = historyRowResult.rows.map((row) => parseOperationRow(row, + { spaceId: this.binding.spaceId })); + const snapshot = replayOhOperationsV1(this.binding.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { throw new OhIntegrityError("Remote operation replay does not reproduce the requested head."); @@ -370,61 +797,387 @@ class OhLibSqlStoreV1 implements OhStoreV1 { this.#assertOpen(); const from = parseOhHeadRefV1(fromValue); if (from === null) throw new TypeError("Invalid change-feed cursor."); - const limit = normalizeLimit(options.limit); - const current = await this.head(); - const fromHead = await this.#headAt(from); - const through = options.through === undefined ? current : await this.#headAt(options.through); + const requestedThrough = options.through === undefined ? undefined : parseOhHeadRefV1(options.through); + if (requestedThrough === null) throw new TypeError("Invalid change-feed through head."); + const limit = normalizeLimit(options.limit, OH_LIBSQL_STORE_LIMITS_V1.changeFeedLimit, + OH_LIBSQL_STORE_LIMITS_V1.changeFeedLimit); + const throughSequence = requestedThrough?.sequence ?? null; + const results = await this.#client.batch([ + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, from.sequence] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, throughSequence] }, + { sql: `SELECT count(*) AS count, coalesce(sum(response_bytes), 0) AS response_bytes FROM ( + SELECT ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS response_bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence > ? + AND candidate.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + ORDER BY candidate.sequence LIMIT ? + )`, args: [this.binding.spaceId, from.sequence, throughSequence, + this.binding.spaceId, limit + 1] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + AS operation WHERE operation.space_id = ? AND operation.sequence > ? + AND operation.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + AND (SELECT coalesce(sum(response_bytes), 0) FROM ( + SELECT ${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")} AS response_bytes + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence > ? + AND candidate.sequence <= coalesce(?, + (SELECT sequence FROM oh_authority_spaces WHERE space_id = ?)) + ORDER BY candidate.sequence LIMIT ? + )) <= ? + ORDER BY operation.sequence LIMIT ?`, + args: [this.binding.spaceId, from.sequence, throughSequence, this.binding.spaceId, + this.binding.spaceId, from.sequence, throughSequence, this.binding.spaceId, limit + 1, + OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes, limit + 1] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }, + ], "read"); + if (results.length !== 6) throw new OhIntegrityError("The remote authority returned an incomplete change-feed batch."); + const [currentResult, fromResult, throughResult, pageSizeResult, pageResult, purgeResult] = results as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, + OhLibSqlResultV1, OhLibSqlResultV1]; + const currentRow = currentResult.rows[0]; + if (currentRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote Oh space disappeared while reading its change feed."); + } + const current = parseHeadRow(currentRow); + const resolveHead = (reference: OhHeadRefV1, result: OhLibSqlResultV1): OhHeadV1 => { + if (reference.sequence === 0) return emptyOhHeadV1(); + const row = result.rows[0]; + if (row === undefined) throw new OhConflictError("A requested change-feed head is not present in this space."); + const operation = parseOperationRow(row, { spaceId: this.binding.spaceId }); + if (operation.spaceId !== this.binding.spaceId || operation.sequence !== reference.sequence + || operation.operationSha256 !== reference.operationSha256) { + throw new OhConflictError("A requested change-feed sequence identifies a different operation head."); + } + return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256: operation.recordsSha256, + sequence: operation.sequence, v: 1 }; + }; + const fromHead = resolveHead(from, fromResult); + const through = requestedThrough === undefined ? current : resolveHead(requestedThrough, throughResult); if (fromHead.sequence > through.sequence || through.sequence > current.sequence) { throw new OhConflictError("The change-feed bounds do not identify one remote history prefix."); } - const rows = (await this.#client.execute({ sql: `SELECT operation_json FROM oh_authority_operations - WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, - args: [this.binding.spaceId, fromHead.sequence, through.sequence, limit + 1] })).rows; - const parsed = rows.map((row) => parseOperationJson(rowValue(row, "operation_json", 0))); + const pageSizeRow = pageSizeResult.rows[0]; + const pageCount = pageSizeRow === undefined ? null : integer(rowValue(pageSizeRow, "count", 0)); + const pageResponseBytes = pageSizeRow === undefined ? null + : integer(rowValue(pageSizeRow, "response_bytes", 1)); + if (pageCount === null || pageCount > limit + 1 || pageResponseBytes === null) { + throw new OhIntegrityError("The remote change feed returned invalid response bounds."); + } + if (pageResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes) { + throw new RangeError("The requested change-feed page exceeds its provider response bound."); + } + const parsed = pageResult.rows.map((row) => parseOperationRow(row, { spaceId: this.binding.spaceId })); + if (parsed.length !== pageCount) { + throw new OhIntegrityError("The remote change feed omitted provider-bounded rows."); + } const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); let prior: OhHeadRefV1 = fromHead; - for (const operation of operations) { - if (operation.sequence !== prior.sequence + 1 + for (const operation of parsed) { + if (operation.spaceId !== this.binding.spaceId + || operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The remote change feed contains a gap or fork."); } prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; } + if (!hasMore && (prior.sequence !== through.sequence + || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The remote change feed does not reach its pinned through head."); + } + const last = operations.at(-1); + const to = last === undefined + ? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence } + : { operationSha256: last.operationSha256, sequence: last.sequence }; return { from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }, - hasMore, operations, through, to: prior, v: 1 }; + hasMore, operations, through, to, v: 1 }; } async #assertMaterializedSnapshot(snapshot: OhSnapshotV1): Promise { - const rows = (await this.#client.execute({ sql: `SELECT record_json FROM oh_authority_records - WHERE space_id = ? ORDER BY record_key`, args: [this.binding.spaceId] })).rows; - const records: KnowledgeGraphRecordV1[] = rows.map((row) => { - const json = rowValue(row, "record_json", 0); + if (snapshot.head.sequence > OH_LIBSQL_STORE_LIMITS_V1.historyOperations) { + throw new RangeError("The libSQL authority exceeds its explicit verification operation bound."); + } + const verificationResults = await this.#client.batch([ + { sql: `SELECT * FROM (WITH bounded_operation AS ( + SELECT * FROM oh_authority_operations + WHERE space_id = ? AND sequence <= ? + ) SELECT count(*) AS operation_count, min(operation.sequence) AS minimum, + max(operation.sequence) AS maximum, + coalesce(sum(length(CAST(operation.operation_json AS BLOB))), 0) AS canonical_bytes, + coalesce(sum(${OPERATION_RESPONSE_BYTES}), 0) AS operation_response_bytes, + (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) AS record_response_bytes, + (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ?) AS dependency_response_bytes, + (SELECT coalesce(sum(${OPERATION_RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_operation_records AS materialized + JOIN bounded_operation AS owner + ON owner.operation_sha256 = materialized.operation_sha256) AS operation_record_response_bytes + FROM bounded_operation AS operation)`, args: [this.binding.spaceId, snapshot.head.sequence, + this.binding.spaceId, this.binding.spaceId] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} + FROM oh_authority_operations AS operation + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(length(CAST(candidate.operation_json AS BLOB))), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + AND (SELECT coalesce(sum(${OPERATION_RESPONSE_BYTES.replaceAll("operation.", "candidate.")}), 0) + FROM oh_authority_operations AS candidate + WHERE candidate.space_id = ? AND candidate.sequence <= ?) <= ? + ORDER BY operation.sequence`, args: [this.binding.spaceId, snapshot.head.sequence, + this.binding.spaceId, snapshot.head.sequence, OH_LIBSQL_STORE_LIMITS_V1.historyBytes, + this.binding.spaceId, snapshot.head.sequence, OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes] }, + { sql: `SELECT record_key, kind, record_sha256, record_json, operation_sha256, sequence + FROM oh_authority_records AS record WHERE record.space_id = ? + AND (SELECT coalesce(sum(${RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_records AS record WHERE record.space_id = ?) <= ? + ORDER BY record.record_key`, args: [this.binding.spaceId, this.binding.spaceId, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT record_key, dependency_key FROM oh_authority_dependencies AS dependency + WHERE dependency.space_id = ? + AND (SELECT coalesce(sum(${DEPENDENCY_RESPONSE_BYTES}), 0) + FROM oh_authority_dependencies AS dependency WHERE dependency.space_id = ?) <= ? + ORDER BY dependency.record_key, dependency.dependency_key`, args: [this.binding.spaceId, + this.binding.spaceId, OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT materialized.space_id, materialized.operation_sha256, materialized.ordinal, + materialized.record_key, materialized.change_kind, materialized.record_sha256 + FROM oh_authority_operation_records AS materialized + JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ? AND operation.sequence <= ? + AND (SELECT coalesce(sum(${OPERATION_RECORD_RESPONSE_BYTES}), 0) + FROM oh_authority_operation_records AS materialized + JOIN oh_authority_operations AS owner + ON owner.operation_sha256 = materialized.operation_sha256 + WHERE owner.space_id = ? AND owner.sequence <= ?) <= ? + ORDER BY operation.sequence, materialized.ordinal`, args: [this.binding.spaceId, + snapshot.head.sequence, this.binding.spaceId, snapshot.head.sequence, + OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + ], "read"); + if (verificationResults.length !== 6) { + throw new OhIntegrityError("The remote authority returned an incomplete verification batch."); + } + const [sizeResult, operationResult, recordResult, dependencyResult, operationRecordResult, headResult] = + verificationResults as [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, + OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1]; + const sizeRow = sizeResult.rows[0]; + const operationCount = sizeRow === undefined ? null : integer(rowValue(sizeRow, "operation_count", 0)); + const minimumValue = sizeRow === undefined ? undefined : rowValue(sizeRow, "minimum", 1); + const maximumValue = sizeRow === undefined ? undefined : rowValue(sizeRow, "maximum", 2); + const minimumSequence = sizeRow === undefined ? null : integer(rowValue(sizeRow, "minimum", 1)); + const maximumSequence = sizeRow === undefined ? null : integer(rowValue(sizeRow, "maximum", 2)); + const historyBytes = sizeRow === undefined ? null : integer(rowValue(sizeRow, "canonical_bytes", 3)); + const operationResponseBytes = sizeRow === undefined ? null + : integer(rowValue(sizeRow, "operation_response_bytes", 4)); + const recordResponseBytes = sizeRow === undefined ? null + : integer(rowValue(sizeRow, "record_response_bytes", 5)); + const dependencyResponseBytes = sizeRow === undefined ? null + : integer(rowValue(sizeRow, "dependency_response_bytes", 6)); + const operationRecordResponseBytes = sizeRow === undefined ? null + : integer(rowValue(sizeRow, "operation_record_response_bytes", 7)); + if (historyBytes === null || historyBytes > OH_LIBSQL_STORE_LIMITS_V1.historyBytes + || operationResponseBytes === null + || operationResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.providerResponseBytes + || recordResponseBytes === null + || recordResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + || dependencyResponseBytes === null + || dependencyResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes + || operationRecordResponseBytes === null + || operationRecordResponseBytes > OH_LIBSQL_STORE_LIMITS_V1.snapshotComponentBytes) { + throw new RangeError("The libSQL authority exceeds its provider-safe verification bounds."); + } + if (operationCount !== snapshot.head.sequence + || (snapshot.head.sequence === 0 && (minimumValue !== null || maximumValue !== null)) + || (snapshot.head.sequence > 0 + && (minimumSequence !== 1 || maximumSequence !== snapshot.head.sequence)) + || operationResult.rows.length !== operationCount) { + throw new OhIntegrityError("The remote operation history does not exactly cover its verified head."); + } + const headRow = headResult.rows[0]; + if (headRow === undefined) throw new OhIntegrityError("The remote authority lost its head during verification."); + if (canonicalJson(parseHeadRow(headRow)) !== canonicalJson(snapshot.head)) { + throw new OhConflictError("The remote authority head changed during verification."); + } + const operations = operationResult.rows.map((row) => { + return parseOperationRow(row, { spaceId: this.binding.spaceId }); + }); + const replayed = replayOhOperationsV1(this.binding.spaceId, operations); + if (canonicalJson(replayed) !== canonicalJson(snapshot)) { + throw new OhIntegrityError("Remote operation replay changed during materialization verification."); + } + const materializedBy = new Map>(); + for (const operation of operations) { + for (const change of operation.changes) { + const key = change.kind === "put" ? change.record.key : change.key; + if (change.kind === "put") materializedBy.set(key, + { operationSha256: operation.operationSha256, sequence: operation.sequence }); + else materializedBy.delete(key); + } + } + const records: KnowledgeGraphRecordV1[] = recordResult.rows.map((row) => { + const json = rowValue(row, "record_json", 3); if (typeof json !== "string") throw new OhIntegrityError("A materialized remote record is not JSON text."); - const parsed = parseKnowledgeGraphRecordV1(JSON.parse(json)); - if (parsed === null || canonicalJson(parsed) !== json) throw new OhIntegrityError("A materialized remote record is invalid."); - return parsed; + let value: unknown; + try { value = JSON.parse(json); } catch { throw new OhIntegrityError("A materialized remote record is invalid."); } + const record = parseKnowledgeGraphRecordV1(value); + const provenance = record === null ? undefined : materializedBy.get(record.key); + if (record === null || canonicalJson(record) !== json + || rowValue(row, "record_key", 0) !== record.key + || rowValue(row, "kind", 1) !== record.kind + || rowValue(row, "record_sha256", 2) !== record.recordSha256 + || provenance === undefined + || rowValue(row, "operation_sha256", 4) !== provenance.operationSha256 + || integer(rowValue(row, "sequence", 5)) !== provenance.sequence) { + throw new OhIntegrityError("A materialized remote record differs from operation replay."); + } + return record; }); if (canonicalJson(records) !== canonicalJson(snapshot.records)) { throw new OhIntegrityError("Remote materialized records do not match operation replay."); } - const dependencyRows = (await this.#client.execute({ sql: `SELECT record_key, dependency_key - FROM oh_authority_dependencies WHERE space_id = ? ORDER BY record_key, dependency_key`, - args: [this.binding.spaceId] })).rows.map((row) => ({ - dependency_key: rowValue(row, "dependency_key", 1), - record_key: rowValue(row, "record_key", 0), - })); + const dependencyRows = dependencyResult.rows.map((row) => ({ + dependency_key: rowValue(row, "dependency_key", 1), + record_key: rowValue(row, "record_key", 0), + })); const expectedDependencies = snapshot.records.flatMap((record) => record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key }))); if (canonicalJson(dependencyRows) !== canonicalJson(expectedDependencies)) { throw new OhIntegrityError("Remote materialized dependencies do not match operation replay."); } + const operationRecordRows = operationRecordResult.rows.map((row) => ({ + change_kind: rowValue(row, "change_kind", 4), + operation_sha256: rowValue(row, "operation_sha256", 1), + ordinal: integer(rowValue(row, "ordinal", 2)), + record_key: rowValue(row, "record_key", 3), + record_sha256: rowValue(row, "record_sha256", 5), + space_id: rowValue(row, "space_id", 0), + })); + const expectedOperationRecords = operations.flatMap((operation) => + operation.changes.map((change, ordinal) => ({ change_kind: change.kind, + operation_sha256: operation.operationSha256, ordinal, + record_key: change.kind === "put" ? change.record.key : change.key, + record_sha256: change.kind === "put" ? change.record.recordSha256 : change.priorSha256, + space_id: this.binding.spaceId }))); + if (canonicalJson(operationRecordRows) !== canonicalJson(expectedOperationRecords)) { + throw new OhIntegrityError("Remote operation-record rows do not match operation replay."); + } } async #operationById(operationId: string): Promise { - const row = await queryOne(this.#client, { sql: `SELECT operation_json FROM oh_authority_operations + const row = await queryOne(this.#client, { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); - return row === null ? null : parseOperationJson(rowValue(row, "operation_json", 0)); + if (row === null) return null; + return parseOperationRow(row, { operationId, spaceId: this.binding.spaceId }); + } + + async #commitPreflight(operationId: string): Promise> { + const results = await this.#client.batch([ + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }, + ], "read"); + if (results.length !== 3) throw new OhIntegrityError("The remote authority returned an incomplete commit preflight."); + const [duplicateResult, headResult, purgeResult] = results as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1]; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote space disappeared during commit preflight."); + } + const duplicateRow = duplicateResult.rows[0]; + return { current: parseHeadRow(headRow), duplicate: duplicateRow === undefined ? null + : parseOperationRow(duplicateRow, { operationId, spaceId: this.binding.spaceId }) }; + } + + async #assertOperationReachable(operation: OhOperationV1, expectedHead: OhHeadV1): Promise { + if (operation.sequence < 1 || operation.sequence > expectedHead.sequence) { + throw new OhIntegrityError("A remote idempotent operation is not reachable from the current head."); + } + const results = await this.#client.batch([ + { sql: `SELECT * FROM (WITH RECURSIVE authority_chain(sequence, operation_sha256) AS ( + SELECT sequence, operation_sha256 FROM oh_authority_operations + WHERE space_id = ? AND sequence = ? AND operation_sha256 = ? + UNION ALL + SELECT candidate.sequence, candidate.operation_sha256 + FROM oh_authority_operations AS candidate + JOIN authority_chain AS prior + ON candidate.space_id = ? AND candidate.sequence = prior.sequence + 1 + AND candidate.parent_operation_sha256 = prior.operation_sha256 + WHERE candidate.sequence <= ? + ) SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum, + (SELECT operation_sha256 FROM authority_chain ORDER BY sequence DESC LIMIT 1) AS terminal_sha256 + FROM authority_chain)`, args: [this.binding.spaceId, operation.sequence, + operation.operationSha256, this.binding.spaceId, expectedHead.sequence] }, + { sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND sequence = ?`, args: [this.binding.spaceId, expectedHead.sequence] }, + { sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [this.binding.spaceId] }, + ], "read"); + if (results.length !== 4) throw new OhIntegrityError("The remote authority returned an incomplete reachability proof."); + const [chainResult, terminalResult, headResult, purgeResult] = results as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1]; + const headRow = headResult.rows[0]; + if (headRow === undefined) { + const purgeRow = purgeResult.rows[0]; + if (purgeRow !== undefined) { + const purge = parsePurgeReceiptRow(purgeRow, this.binding.spaceId, this.binding.bindingSha256); + this.#purged = purge; + throw new OhPurgedSpaceError(purge); + } + throw new OhIntegrityError("The remote space disappeared during an idempotency proof."); + } + const current = parseHeadRow(headRow); + if (canonicalJson(current) !== canonicalJson(expectedHead)) { + throw new OhConflictError("The remote space head changed during an idempotency proof."); + } + const chain = chainResult.rows[0]; + const count = chain === undefined ? null : integer(rowValue(chain, "count", 0)); + const minimum = chain === undefined ? null : integer(rowValue(chain, "minimum", 1)); + const maximum = chain === undefined ? null : integer(rowValue(chain, "maximum", 2)); + const terminalSha256 = chain === undefined ? null : rowValue(chain, "terminal_sha256", 3); + if (count !== expectedHead.sequence - operation.sequence + 1 + || minimum !== operation.sequence || maximum !== expectedHead.sequence + || terminalSha256 !== expectedHead.operationSha256) { + throw new OhIntegrityError("A remote idempotent operation has no exact path to the current head."); + } + const terminalRow = terminalResult.rows[0]; + if (terminalRow === undefined || expectedHead.operationSha256 === null) { + throw new OhIntegrityError("The remote current head operation is missing."); + } + const terminal = parseOperationRow(terminalRow, { operationSha256: expectedHead.operationSha256, + spaceId: this.binding.spaceId }); + if (terminal.sequence !== expectedHead.sequence + || terminal.graphRevisionSha256 !== expectedHead.graphRevisionSha256 + || terminal.recordsSha256 !== expectedHead.recordsSha256) { + throw new OhIntegrityError("The remote space head differs from its terminal canonical operation."); + } } async commit(input: OhCommitInputV1): Promise { @@ -433,24 +1186,34 @@ class OhLibSqlStoreV1 implements OhStoreV1 { const operationId = safeCode(input.operationId); const changes = canonicalKnowledgeGraphChangesV1(input.changes); if (actorId === null || operationId === null || changes.length === 0) throw new TypeError("Invalid Oh commit input."); - const duplicate = await this.#operationById(operationId); + if (changes.length > OH_LIBSQL_STORE_LIMITS_V1.changesPerCommit) { + throw new RangeError("A direct libSQL commit exceeds its change-count bound."); + } + const dependencies = changes.reduce((count, change) => count + + (change.kind === "put" ? change.record.dependencies.length : 0), 0); + if (dependencies > OH_LIBSQL_STORE_LIMITS_V1.dependenciesPerCommit) { + throw new RangeError("A direct libSQL commit exceeds its dependency-count bound."); + } + const { current, duplicate } = await this.#commitPreflight(operationId); if (duplicate !== null) { + await this.#assertOperationReachable(duplicate, current); if (duplicate.actorId !== actorId || canonicalJson(duplicate.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return duplicate; } - const current = await this.head(); if (!Number.isSafeInteger(input.expectedHead.generation) || input.expectedHead.generation < 0 || current.generation !== input.expectedHead.generation || current.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current remote space head."); } - const snapshot = await this.snapshot({ head: current }); - await this.#assertMaterializedSnapshot(snapshot); + const snapshot = await this.#currentMaterializedSnapshot(current, OH_GRAPH_LIMITS_V1.recordsPerSnapshot); const transition = transitionOhSnapshotV1({ actorId, changes, instant: input.instant ?? canonicalNow(), operationId, snapshot, spaceId: this.binding.spaceId }); const operation = transition.operation; + if (utf8ByteLength(canonicalJson(operation)) > OH_LIBSQL_STORE_LIMITS_V1.operationBytes) { + throw new RangeError("A direct libSQL operation exceeds its canonical byte bound."); + } const existsOperation = "EXISTS (SELECT 1 FROM oh_authority_operations WHERE operation_sha256 = ?)"; const statements: OhLibSqlStatementV1[] = [{ sql: `INSERT INTO oh_authority_operations(operation_sha256, space_id, sequence, @@ -467,10 +1230,11 @@ class OhLibSqlStoreV1 implements OhStoreV1 { for (const [ordinal, change] of operation.changes.entries()) { const key = change.kind === "put" ? change.record.key : change.key; const digest = change.kind === "put" ? change.record.recordSha256 : change.priorSha256; - statements.push({ sql: `INSERT INTO oh_authority_operation_records(operation_sha256, + statements.push({ sql: `INSERT INTO oh_authority_operation_records(space_id, operation_sha256, ordinal, record_key, change_kind, record_sha256) - SELECT ?, ?, ?, ?, ? WHERE ${existsOperation}`, - args: [operation.operationSha256, ordinal, key, change.kind, digest, operation.operationSha256] }); + SELECT ?, ?, ?, ?, ?, ? WHERE ${existsOperation}`, + args: [this.binding.spaceId, operation.operationSha256, ordinal, key, change.kind, digest, + operation.operationSha256] }); statements.push({ sql: `DELETE FROM oh_authority_dependencies WHERE space_id = ? AND record_key = ? AND ${existsOperation}`, args: [this.binding.spaceId, key, operation.operationSha256] }); if (change.kind === "put") { @@ -507,20 +1271,41 @@ class OhLibSqlStoreV1 implements OhStoreV1 { SELECT 'invalid' WHERE NOT EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ? AND generation = ? AND head_operation_sha256 = ?)`, args: [this.binding.spaceId, operation.sequence, operation.operationSha256] }); + statements.push({ sql: `SELECT ${OPERATION_ROW_COLUMNS} FROM oh_authority_operations + WHERE space_id = ? AND operation_id = ?`, args: [this.binding.spaceId, operationId] }); + statements.push({ sql: `SELECT generation, graph_revision_sha256, head_operation_sha256, records_sha256, sequence + FROM oh_authority_spaces WHERE space_id = ?`, args: [this.binding.spaceId] }); + let writeResults: readonly OhLibSqlResultV1[]; try { - await this.#client.batch(statements, "write"); + writeResults = await this.#client.batch(statements, "write"); } catch (error) { const raced = await this.#operationById(operationId); - if (raced !== null && raced.actorId === actorId - && canonicalJson(raced.changes) === canonicalJson(changes)) return raced; const head = await this.head(); + if (raced !== null && raced.actorId === actorId + && canonicalJson(raced.changes) === canonicalJson(changes)) { + await this.#assertOperationReachable(raced, head); + return raced; + } if (head.operationSha256 !== current.operationSha256) { throw new OhConflictError("The remote space head changed while committing."); } throw error; } - const persisted = await this.#operationById(operationId); - if (persisted === null || canonicalJson(persisted) !== canonicalJson(operation)) { + if (writeResults.length !== statements.length) { + throw new OhIntegrityError("The remote authority returned an incomplete commit result batch."); + } + const persistedRow = writeResults.at(-2)?.rows[0]; + const persistedHeadRow = writeResults.at(-1)?.rows[0]; + if (persistedRow === undefined || persistedHeadRow === undefined) { + throw new OhIntegrityError("The remote authority omitted its persisted commit result."); + } + const persisted = parseOperationRow(persistedRow, { operationId, spaceId: this.binding.spaceId }); + const persistedHead = parseHeadRow(persistedHeadRow); + if (canonicalJson(persisted) !== canonicalJson(operation) + || persistedHead.operationSha256 !== operation.operationSha256 + || persistedHead.sequence !== operation.sequence + || persistedHead.graphRevisionSha256 !== operation.graphRevisionSha256 + || persistedHead.recordsSha256 !== operation.recordsSha256) { throw new OhIntegrityError("The remote authority did not persist the committed operation exactly."); } return persisted; @@ -545,16 +1330,41 @@ class OhLibSqlStoreV1 implements OhStoreV1 { this.#assertOpen(); const snapshot = await this.snapshot(); await this.#assertMaterializedSnapshot(snapshot); - const countRow = await queryOne(this.#client, { sql: `SELECT count(*) AS count - FROM oh_authority_operations WHERE space_id = ?`, args: [this.binding.spaceId] }); - const operations = countRow === null ? null : integer(rowValue(countRow, "count", 0)); - if (operations === null || operations !== snapshot.head.sequence) { - throw new OhIntegrityError("Remote operation count does not match its head."); - } - return { head: snapshot.head, integrity: "verified", operations, + return { head: snapshot.head, integrity: "verified", operations: snapshot.head.sequence, records: snapshot.records.length, v: 1 }; } + async #assertPurgeComplete(expected: OhSpacePurgeReceiptV1): Promise { + const tables = ["oh_authority_spaces", "oh_authority_bindings", "oh_authority_operations", + "oh_authority_operation_records", "oh_authority_records", "oh_authority_dependencies"] as const; + const results = await this.#client.batch([ + { sql: PURGE_ROW_SELECT, + args: [this.binding.spaceId] }, + ...tables.map((table) => ({ sql: `SELECT count(*) AS count FROM ${table} WHERE space_id = ?`, + args: [this.binding.spaceId] })), + { sql: `SELECT count(*) AS count FROM oh_authority_operation_records AS materialized + LEFT JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.operation_sha256 IS NULL OR operation.space_id <> materialized.space_id` }, + ], "read"); + const receiptRow = results[0]?.rows[0]; + if (receiptRow === undefined + || canonicalJson(parsePurgeReceiptRow(receiptRow, this.binding.spaceId, + this.binding.bindingSha256)) !== canonicalJson(expected)) { + throw new OhIntegrityError("The remote purge receipt differs from the requested purge."); + } + for (let index = 0; index < tables.length; index += 1) { + const countRow = results[index + 1]?.rows[0]; + if (countRow === undefined || integer(rowValue(countRow, "count", 0)) !== 0) { + throw new OhIntegrityError(`Remote purge left rows in ${tables[index]}.`); + } + } + const orphanRow = results[tables.length + 1]?.rows[0]; + if (orphanRow === undefined || integer(rowValue(orphanRow, "count", 0)) !== 0) { + throw new OhIntegrityError("Remote purge left an orphaned or cross-space operation record."); + } + } + async purgeWorkingSpace(purgedAt: string): Promise { this.#assertOpen(); if (this.binding.profile.profileKind !== "working" @@ -563,7 +1373,11 @@ class OhLibSqlStoreV1 implements OhStoreV1 { } for (let attempt = 0; attempt < 3; attempt += 1) { const existing = await this.#readPurge(); - if (existing !== null) { this.#purged = existing; return existing; } + if (existing !== null) { + await this.#assertPurgeComplete(existing); + this.#purged = existing; + return existing; + } const head = await this.head(); const receipt = createOhSpacePurgeReceiptV1({ binding: this.binding, priorHead: head, purgedAt }); const receiptExists = "EXISTS (SELECT 1 FROM oh_authority_purges WHERE space_id = ? AND receipt_sha256 = ?)"; @@ -579,9 +1393,9 @@ class OhLibSqlStoreV1 implements OhStoreV1 { sql: `DELETE FROM ${table} WHERE space_id = ? AND ${receiptExists}`, args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256], }); - statements.push({ sql: `DELETE FROM oh_authority_operation_records WHERE operation_sha256 IN - (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) - AND ${receiptExists}`, + statements.push({ sql: `DELETE FROM oh_authority_operation_records + WHERE operation_sha256 IN (SELECT operation_sha256 FROM oh_authority_operations WHERE space_id = ?) + AND ${receiptExists}`, args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); statements.push(guardedDelete("oh_authority_dependencies")); statements.push(guardedDelete("oh_authority_records")); @@ -590,15 +1404,34 @@ class OhLibSqlStoreV1 implements OhStoreV1 { statements.push(guardedDelete("oh_authority_spaces")); statements.push({ sql: `INSERT INTO oh_authority_commit_guards(value) SELECT 'invalid' WHERE EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operations WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operation_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_operation_records AS materialized + LEFT JOIN oh_authority_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.operation_sha256 IS NULL OR operation.space_id <> materialized.space_id) + OR EXISTS (SELECT 1 FROM oh_authority_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_dependencies WHERE space_id = ?) OR NOT ${receiptExists}`, - args: [this.binding.spaceId, this.binding.spaceId, receipt.receiptSha256] }); + args: [this.binding.spaceId, this.binding.spaceId, this.binding.spaceId, + this.binding.spaceId, this.binding.spaceId, this.binding.spaceId, + this.binding.spaceId, receipt.receiptSha256] }); try { await this.#client.batch(statements, "write"); } catch { const raced = await this.#readPurge(); - if (raced !== null) { this.#purged = raced; return raced; } + if (raced !== null) { + await this.#assertPurgeComplete(raced); + this.#purged = raced; + return raced; + } continue; } const persisted = await this.#readPurge(); - if (persisted !== null) { this.#purged = persisted; return persisted; } + if (persisted !== null) { + await this.#assertPurgeComplete(persisted); + this.#purged = persisted; + return persisted; + } } throw new OhConflictError("The remote working space changed repeatedly while purging."); } diff --git a/src/memory.test.ts b/src/memory.test.ts new file mode 100644 index 0000000..2f8cc24 --- /dev/null +++ b/src/memory.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from "bun:test"; + +import { canonicalSha256, type JsonValue } from "./canonical"; +import { OhRecordCodecRegistry } from "./contract"; +import { createKnowledgeGraphRecordV1 } from "./graph"; +import { + createOhMemoryAgentV1, + OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1, + type OhMemoryAuthoritySourceV1, + type OhMemoryProofV1, +} from "./memory"; +import { + createOhProjectionLiteralV1, + createOhProjectionQueryV1, + createOhProjectionRulePackV1, + createOhProjectionRuleV1, + ohProjectionVariableV1, +} from "./projection"; +import { createOhSqliteStoreAuthorityV1 } from "./sqlite/port"; +import { + OH_CANONICAL_STORE_PROFILE_V1, + OH_WORKING_STORE_PROFILE_V1, + OhIntegrityError, + OhProfileError, + type OhStoreV1, +} from "./store"; + +function entity(key: string, name: string) { + return createKnowledgeGraphRecordV1({ dependencies: [], key, kind: "entity", v: 1, + value: { name } }); +} + +async function put(store: OhStoreV1, key: string, name: string, operationId: string) { + return await store.commit({ actorId: "test.host", changes: [ + { kind: "put", record: entity(key, name), v: 1 }, + ], expectedHead: await store.head(), instant: "2026-08-29T12:00:00.000Z", operationId }); +} + +function entityCodecs() { + return new OhRecordCodecRegistry().register({ kind: "entity", parse: (value) => { + if (typeof value !== "object" || value === null || Array.isArray(value) + || typeof (value as { name?: unknown }).name !== "string") return null; + return { name: (value as { name: string }).name } satisfies JsonValue; + } }); +} + +function visibleProgram() { + const lane = ohProjectionVariableV1("lane"); + const key = ohProjectionVariableV1("key"); + const kind = ohProjectionVariableV1("kind"); + const digest = ohProjectionVariableV1("digest"); + const literal = (relation: string, ...terms: ReturnType[]) => + createOhProjectionLiteralV1({ relation, terms }); + return { + programId: "memory.visible-records", + purpose: "answer.research", + query: createOhProjectionQueryV1({ find: ["lane", "key", "digest"], limit: 50, + queryId: "memory.visible-records", where: [literal("memory.visible", lane, key, digest)] }), + rulePack: createOhProjectionRulePackV1({ rulePackId: "memory.visible-records", + rulePackRevision: 1, rules: [createOhProjectionRuleV1({ + body: [literal("memory.record", lane, key, kind, digest)], + head: literal("memory.visible", lane, key, digest), ruleId: "memory.visible-records", })] }), + } as const; +} + +function nameProgram() { + const lane = ohProjectionVariableV1("lane"); + const key = ohProjectionVariableV1("key"); + const name = ohProjectionVariableV1("name"); + const literal = (relation: string, ...terms: ReturnType[]) => + createOhProjectionLiteralV1({ relation, terms }); + return { + programId: "memory.domain-names", + purpose: "answer.domain", + query: createOhProjectionQueryV1({ find: ["lane", "key", "name"], limit: 50, + queryId: "memory.domain-names", where: [literal("domain.visible-name", lane, key, name)] }), + rulePack: createOhProjectionRulePackV1({ rulePackId: "memory.domain-names", + rulePackRevision: 1, rules: [createOhProjectionRuleV1({ + body: [literal("domain.name", lane, key, name)], + head: literal("domain.visible-name", lane, key, name), ruleId: "memory.domain-names", })] }), + } as const; +} + +function physicalSources(proofs: readonly OhMemoryProofV1[]) { + const sources: OhMemoryAuthoritySourceV1[] = []; + const visit = (proof: OhMemoryProofV1) => { + if (proof.kind === "fact") sources.push(...proof.sources); + else if (proof.kind === "derived") proof.premises.forEach(visit); + }; + proofs.forEach(visit); + return sources; +} + +async function fixture() { + const canonical = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:canonical", spaceId: "canonical" }); + const working = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:working", spaceId: "working" }); + await put(canonical.store, "entity:shared", "Reviewed", "op_canonical_shared"); + await put(canonical.store, "entity:canonical", "Canonical", "op_canonical_only"); + const canonicalHead = await canonical.store.head(); + let now = new Date("2026-08-29T12:00:00.000Z"); + const clockOrigin = now.getTime(); + let monotonicNow = 0; + const baseProgram = visibleProgram(); + const agent = await createOhMemoryAgentV1({ + actorId: "test.memory-agent", + canonical: { authorityId: "authority.canonical", expectedBindingSha256: canonical.store.binding.bindingSha256, + expectedHead: canonicalHead, store: canonical.store }, + now: () => now, + nominationRoutes: [{ destinationPurpose: "kb.review", nominationId: "kb.review" }], + monotonicNow: () => monotonicNow, + programs: [baseProgram, { ...baseProgram, programId: "memory.visible-records-alternate", + purpose: "answer.alternate" }, { ...baseProgram, + evaluation: { maximumRounds: 2 }, programId: "memory.visible-records-bounded" }], + working: { authorityId: "authority.working", codecs: entityCodecs(), + expectedBindingSha256: working.store.binding.bindingSha256, store: working.store }, + }); + return { agent, canonical, canonicalHead, setNow(value: string) { + now = new Date(value); monotonicNow = now.getTime() - clockOrigin; + }, working }; +} + +describe("experimental composite Oh memory", () => { + test("keeps two physical authorities explicit and makes working conflicts visible", async () => { + expect(Object.isFrozen(OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.relations)).toBe(true); + const value = await fixture(); + const emptyWorkingHead = await value.working.store.head(); + const firstRemember = { expectedHead: { + generation: emptyWorkingHead.generation, operationSha256: emptyWorkingHead.operationSha256, + }, requestId: "op_working_shared", + puts: [{ dependencies: [], key: "entity:shared", kind: "entity", v: 1, + value: { name: "Proposed correction" } }], tombstones: [], v: 1 } as const; + const receipt = await value.agent.remember(firstRemember); + expect(receipt).toMatchObject({ actorId: "test.memory-agent", authorityId: "authority.working", + lane: "working", requestId: "op_working_shared", status: "committed" }); + expect(Object.keys(receipt)).not.toContain("changes"); + expect(await value.agent.remember(firstRemember)).toEqual(receipt); + const head = await value.working.store.head(); + await value.agent.remember({ expectedHead: { + generation: head.generation, operationSha256: head.operationSha256, + }, requestId: "op_working_only", + puts: [{ dependencies: [], key: "entity:working", kind: "entity", v: 1, + value: { name: "Working" } }], tombstones: [], v: 1 }); + + const result = await value.agent.query({ programId: "memory.visible-records", v: 1 }); + expect(result.authority).toBe("derived"); + expect(result.rows.map(({ values }) => values.slice(0, 2))).toEqual([ + ["canonical", "entity:canonical"], ["canonical", "entity:shared"], + ["working", "entity:shared"], ["working", "entity:working"], + ]); + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]?.key).toBe("entity:shared"); + expect(result.identity.canonical.authorityId).toBe("authority.canonical"); + expect(result.identity.working.authorityId).toBe("authority.working"); + expect(result.identity.canonical.head).toEqual(value.canonicalHead); + expect(result.identity.working.head).toEqual(await value.working.store.head()); + expect(result.identity.purpose).toBe("answer.research"); + expect(Object.isFrozen(result.rows)).toBe(true); + expect(Object.isFrozen(result.rows[0]?.values)).toBe(true); + expect(() => (result.rows as unknown as unknown[]).splice(0, 1)).toThrow(); + expect(() => (result.rows[0]!.values as unknown as unknown[]).push("tamper")).toThrow(); + + const workingRow = result.rows.findIndex(({ values }) => values[0] === "working"); + const explanation = await value.agent.explain({ resultSha256: result.resultSha256, + row: workingRow, token: result.explainCapability.token, v: 1 }); + expect(explanation.premiseAuthority).toBe("working"); + expect(physicalSources(explanation.proofs)).toEqual([expect.objectContaining({ + authorityId: "authority.working", bindingSha256: value.working.store.binding.bindingSha256, + head: await value.working.store.head(), key: "entity:shared", lane: "working", + })]); + await value.canonical.store.close(); await value.working.store.close(); + }); + + test("binds both heads and host-owned program purpose and evaluation into identity", async () => { + const value = await fixture(); + const first = await value.agent.query({ programId: "memory.visible-records", v: 1 }); + const purposeChanged = await value.agent.query({ + programId: "memory.visible-records-alternate", v: 1 }); + const evaluationChanged = await value.agent.query({ + programId: "memory.visible-records-bounded", v: 1 }); + expect(purposeChanged.identity.purpose).toBe("answer.alternate"); + expect(evaluationChanged.identity.evaluationSha256).not.toBe(first.identity.evaluationSha256); + expect(new Set([first.identity.memorySha256, purposeChanged.identity.memorySha256, + evaluationChanged.identity.memorySha256]).size).toBe(3); + + const workingHead = await value.working.store.head(); + await value.agent.remember({ expectedHead: { + generation: workingHead.generation, operationSha256: workingHead.operationSha256, + }, requestId: "op_identity_change", + puts: [{ dependencies: [], key: "entity:new", kind: "entity", v: 1, + value: { name: "New" } }], tombstones: [], v: 1 }); + const headChanged = await value.agent.query({ programId: "memory.visible-records", v: 1 }); + expect(headChanged.identity.memorySha256).not.toBe(first.identity.memorySha256); + expect(headChanged.identity.working.head.sequence).toBe(1); + await value.canonical.store.close(); await value.working.store.close(); + }); + + test("binds host-owned domain extractors while retaining physical sources", async () => { + const canonical = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:domain-c", spaceId: "domain-c" }); + const working = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:domain-w", spaceId: "domain-w" }); + await put(canonical.store, "entity:domain", "Domain name", "op_domain_name"); + const agent = await createOhMemoryAgentV1({ actorId: "test.domain-agent", + canonical: { authorityId: "authority.domain-c", + expectedBindingSha256: canonical.store.binding.bindingSha256, + expectedHead: await canonical.store.head(), store: canonical.store }, extractors: [{ + extractorId: "domain.mutator", + extractorSha256: canonicalSha256({ extractor: "domain.mutator", v: 1 }), + relations: ["domain.mutation-attempt"], + extract: ({ lane, record }) => { + try { (record.value as { name: string }).name = "Mutated"; } catch { /* frozen input */ } + return [{ relation: "domain.mutation-attempt", tuple: [lane, record.key], v: 1 }]; + }, + }, { extractorId: "domain.names", + extractorSha256: canonicalSha256({ extractor: "domain.names", v: 1 }), + relations: ["domain.name"], extract: ({ lane, record }) => [{ relation: "domain.name", + tuple: [lane, record.key, (record.value as { name: string }).name], v: 1 }], + }], programs: [nameProgram()], working: { authorityId: "authority.domain-w", + codecs: entityCodecs(), expectedBindingSha256: working.store.binding.bindingSha256, + store: working.store } }); + const result = await agent.query({ programId: "memory.domain-names", v: 1 }); + expect(result.rows.map(({ values }) => values)).toEqual([ + ["canonical", "entity:domain", "Domain name"], + ]); + const explanation = await agent.explain({ resultSha256: result.resultSha256, row: 0, + token: result.explainCapability.token, v: 1 }); + expect(physicalSources(explanation.proofs)[0]).toMatchObject({ key: "entity:domain", lane: "canonical" }); + const topProof = explanation.proofs[0]; + expect(topProof?.kind).toBe("derived"); + if (topProof?.kind !== "derived") throw new Error("Expected a derived domain proof."); + const domainProof = topProof.premises[0]; + expect(domainProof?.kind).toBe("fact"); + if (domainProof?.kind !== "fact") throw new Error("Expected a domain fact premise."); + expect(domainProof.factPolicy).toMatchObject({ extractorId: "domain.names", kind: "domain" }); + await canonical.store.close(); await working.store.close(); + }); + + test("captures host options and detaches store-owned snapshots before the first await", async () => { + const canonical = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:captured-c", spaceId: "captured-c" }); + const working = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:captured-w", spaceId: "captured-w" }); + const swapped = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:swapped-w", spaceId: "swapped-w" }); + await put(swapped.store, "entity:swapped", "Swapped", "op_swapped"); + let leakedSnapshot: { head: unknown; records: ReturnType[]; v: 1 } | undefined; + const canonicalStore = { + binding: canonical.store.binding, + head: () => canonical.store.head(), + snapshot: async (options?: Parameters[0]) => { + const snapshot = await canonical.store.snapshot(options); + leakedSnapshot = { head: { ...snapshot.head }, records: [...snapshot.records], v: 1 }; + return leakedSnapshot; + }, + } as unknown as OhStoreV1; + const program = visibleProgram(); + const mutableOptions = { actorId: "test.captured-agent", canonical: { + authorityId: "authority.captured-c", expectedBindingSha256: canonical.store.binding.bindingSha256, + expectedHead: await canonical.store.head(), store: canonicalStore }, + nominationRoutes: [{ destinationPurpose: "kb.review", nominationId: "kb.review" }], + programs: [program], working: { authorityId: "authority.captured-w", codecs: entityCodecs(), + expectedBindingSha256: working.store.binding.bindingSha256, store: working.store } }; + const pending = createOhMemoryAgentV1(mutableOptions); + mutableOptions.working.store = swapped.store; + (mutableOptions.programs[0] as { purpose: string }).purpose = "attacker.relabel"; + const agent = await pending; + if (leakedSnapshot === undefined) throw new Error("Expected the test store to return a snapshot."); + leakedSnapshot.records.push(entity("entity:mutated-snapshot", "Mutated")); + const head = await working.store.head(); + const receipt = await agent.remember({ expectedHead: { generation: head.generation, + operationSha256: head.operationSha256 }, puts: [{ dependencies: [], key: "entity:bound", + kind: "entity", v: 1, value: { name: "Bound" } }], requestId: "captured-remember", + tombstones: [], v: 1 }); + expect(receipt.bindingSha256).toBe(working.store.binding.bindingSha256); + const result = await agent.query({ programId: "memory.visible-records", v: 1 }); + expect(result.identity.purpose).toBe("answer.research"); + expect(result.rows.map(({ values }) => values.slice(0, 2))).toEqual([ + ["working", "entity:bound"], + ]); + const nomination = await agent.nominate({ nominationId: "kb.review", + roots: ["entity:bound"], v: 1 }); + expect(nomination.closure.records.map(({ key }) => key)).toEqual(["entity:bound"]); + await canonical.store.close(); await working.store.close(); await swapped.store.close(); + }); + + test("rejects query injection and misbound or expired explanation capabilities", async () => { + const value = await fixture(); + expect(Object.keys(value.agent).sort()).toEqual(["explain", "nominate", "query", "remember"]); + await expect(value.agent.query({ programId: "memory.visible-records", + query: { relation: "attacker" }, v: 1 })) + .rejects.toThrow(TypeError); + await expect(value.agent.query({ programId: "memory.visible-records", + purpose: "attacker.label", v: 1 })).rejects.toThrow(TypeError); + await expect(value.agent.query({ programId: "unknown", v: 1 })).rejects.toThrow("Unknown named"); + const result = await value.agent.query({ programId: "memory.visible-records", v: 1 }); + await expect(value.agent.explain({ resultSha256: "f".repeat(64), row: 0, + token: result.explainCapability.token, v: 1 })).rejects.toThrow(OhProfileError); + await expect(value.agent.explain({ resultSha256: result.resultSha256, row: 0, + token: "A".repeat(43), v: 1 })).rejects.toThrow(OhProfileError); + value.setNow("2026-08-29T12:10:00.000Z"); + const later = await value.agent.query({ programId: "memory.visible-records", v: 1 }); + value.setNow("2026-08-29T12:05:00.000Z"); + await expect(value.agent.explain({ resultSha256: later.resultSha256, row: 0, + token: later.explainCapability.token, v: 1 })).rejects.toThrow("monotonic clock regressed"); + value.setNow("2026-08-29T12:15:00.000Z"); + await expect(value.agent.explain({ resultSha256: result.resultSha256, row: 0, + token: result.explainCapability.token, v: 1 })).rejects.toThrow(OhProfileError); + value.setNow("invalid"); + await expect(value.agent.explain({ resultSha256: result.resultSha256, row: 0, + token: result.explainCapability.token, v: 1 })).rejects.toThrow(TypeError); + await value.canonical.store.close(); await value.working.store.close(); + }); + + test("prepares an exact working closure without writing canonical authority", async () => { + const value = await fixture(); + const beforeCanonical = await value.canonical.store.head(); + const head = await value.working.store.head(); + await value.agent.remember({ expectedHead: { + generation: head.generation, operationSha256: head.operationSha256, + }, requestId: "op_nomination", + puts: [{ dependencies: [], key: "entity:candidate", kind: "entity", v: 1, + value: { name: "Candidate" } }, { dependencies: [], key: "entity:other", kind: "entity", v: 1, + value: { name: "Other" } }], tombstones: [], v: 1 }); + const nomination = await value.agent.nominate({ nominationId: "kb.review", + roots: ["entity:candidate"], v: 1 }); + expect(nomination.status).toBe("prepared"); + expect(nomination.destinationPurpose).toBe("kb.review"); + expect(nomination.source.lane).toBe("working"); + expect(nomination.closure.records.map(({ key }) => key)).toEqual(["entity:candidate"]); + expect(await value.canonical.store.head()).toEqual(beforeCanonical); + const exactHead = await value.working.store.head(); + const originalExport = value.working.store.exportDependencyClosure.bind(value.working.store); + const substituted = await originalExport({ head: { operationSha256: exactHead.operationSha256, + sequence: exactHead.sequence }, roots: ["entity:other"] }); + (value.working.store as unknown as { exportDependencyClosure: () => Promise }) + .exportDependencyClosure = async () => substituted; + await expect(value.agent.nominate({ nominationId: "kb.review", + roots: ["entity:candidate"], v: 1 })).rejects.toThrow("substituted different roots"); + await expect(value.agent.nominate({ nominationId: "attacker.destination", + roots: ["entity:candidate"], v: 1 })).rejects.toThrow("Unknown named"); + await expect(value.agent.nominate({ nominationId: "kb.review", + roots: Array.from({ length: 1_025 }, (_, index) => `entity:${index}`), v: 1 })) + .rejects.toThrow(TypeError); + await value.canonical.store.close(); await value.working.store.close(); + }); + + test("fails closed on host binding, profile, and pinned-head mistakes", async () => { + const canonical = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:c", spaceId: "c" }); + const working = createOhSqliteStoreAuthorityV1({ path: ":memory:", + profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:w", spaceId: "w" }); + const head = await canonical.store.head(); + await expect(createOhMemoryAgentV1({ actorId: "test.agent", + canonical: { authorityId: "authority.c", + expectedBindingSha256: working.store.binding.bindingSha256, expectedHead: head, + store: canonical.store }, programs: [visibleProgram()], working: { authorityId: "authority.w", + codecs: entityCodecs(), expectedBindingSha256: working.store.binding.bindingSha256, + store: working.store } })).rejects.toThrow(OhIntegrityError); + await expect(createOhMemoryAgentV1({ actorId: "test.agent", + canonical: { authorityId: "authority.c", + expectedBindingSha256: canonical.store.binding.bindingSha256, + expectedHead: { ...head, recordsSha256: "f".repeat(64) as typeof head.recordsSha256 }, + store: canonical.store }, programs: [visibleProgram()], working: { authorityId: "authority.w", + codecs: entityCodecs(), expectedBindingSha256: working.store.binding.bindingSha256, + store: working.store } })).rejects.toThrow(); + await expect(createOhMemoryAgentV1({ actorId: "test.agent", + canonical: { authorityId: "same", + expectedBindingSha256: canonical.store.binding.bindingSha256, expectedHead: head, + store: canonical.store }, programs: [visibleProgram()], working: { authorityId: "same", + codecs: entityCodecs(), expectedBindingSha256: working.store.binding.bindingSha256, + store: working.store } })).rejects.toThrow(OhProfileError); + await canonical.store.close(); await working.store.close(); + }); +}); diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..0a78b19 --- /dev/null +++ b/src/memory.ts @@ -0,0 +1,957 @@ +import { randomBytes } from "node:crypto"; + +import { + canonicalJson, + canonicalSha256, + hasExactKeys, + isPlainRecord, + parseCanonicalInstantV1, + parseSha256Hex, + safeCode, + utf8ByteLength, + type JsonPrimitive, + type Sha256Hex, +} from "./canonical"; +import { type OhRecordCodecRegistry } from "./contract"; +import { + createKnowledgeGraphRecordV1, + knowledgeGraphRecordRefV1, + type KnowledgeGraphRecordV1, +} from "./graph"; +import { + OH_PROJECTION_SEMANTICS_V1, + OH_PROJECTION_LIMITS_V1, + createOhProjectionDatasetV1, + createOhProjectionFactV1, + createOhProjectionRecordFactsV1, + createOhProjectionSnapshotV1, + evaluateOhProjectionV1, + parseOhProjectionQueryV1, + parseOhProjectionRulePackV1, + type OhProjectionAtomV1, + type OhProjectionDatasetV1, + type OhProjectionEvaluationOptionsV1, + type OhProjectionFactV1, + type OhProjectionProofV1, + type OhProjectionQueryV1, + type OhProjectionResultRowV1, + type OhProjectionRulePackV1, + type OhProjectionSnapshotV1, +} from "./projection"; +import { + OhIntegrityError, + OH_DEPENDENCY_CLOSURE_LIMITS_V1, + OhProfileError, + OhSemanticBundleIngressV1, + parseOhHeadV1, + parseOhStoreBindingV1, + verifyOhDependencyClosureAgainstV1, + type OhDependencyClosureV1, + type OhHeadV1, + type OhSnapshotV1, + type OhStoreBindingV1, + type OhStoreV1, +} from "./store"; +export const OH_MEMORY_FORMAT_VERSION_V1 = 1 as const; +export const OH_MEMORY_CONFLICT_POLICY_V1 = "visible-conflicts.v1" as const; +export const OH_MEMORY_LIMITS_V1 = Object.freeze({ + explainCapabilityEntryBytes: 32 * 1024 * 1024, + explainCapabilities: 256, + explainCapabilityLifetimeMs: 15 * 60 * 1_000, + explainCapabilityTotalBytes: 64 * 1024 * 1024, + factsPerRecordPerExtractor: 512, + maximumExtractorInvocations: 262_144, + maximumExtractors: 32, + maximumNominationRoutes: 64, + maximumPrograms: 128, + maximumRecordsPerLane: 8_192, + maximumSyntheticRecords: 16_384, + rememberBytes: 8 * 1024 * 1024, + resultBytes: 32 * 1024 * 1024, + snapshotBytesPerLane: 32 * 1024 * 1024, + relationsPerExtractor: 64, +}); + +const memoryFactPackPayload = Object.freeze({ + factPackId: "oh.memory.composite-facts", + factPackRevision: 1, + relations: Object.freeze(["memory.agreement", "memory.conflict", "memory.dependency", "memory.record"]), + semantics: OH_PROJECTION_SEMANTICS_V1, + v: 1 as const, +}); + +export const OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1 = Object.freeze({ + ...memoryFactPackPayload, + extractorSha256: canonicalSha256(memoryFactPackPayload), +}); + +export type OhMemoryLaneV1 = "canonical" | "working"; + +export type OhMemoryAuthoritySourceV1 = Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + key: string; + lane: OhMemoryLaneV1; + recordSha256: Sha256Hex; + snapshotSha256: Sha256Hex; + v: 1; +}>; + +export type OhMemoryProofV1 = + | Readonly<{ + factPolicy: OhMemoryFactPolicyV1; + kind: "fact"; + relation: string; + sources: readonly OhMemoryAuthoritySourceV1[]; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }> + | Readonly<{ + kind: "derived"; + premises: readonly OhMemoryProofV1[]; + premisesTruncated: boolean; + relation: string; + ruleId: string; + ruleSha256: Sha256Hex; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }> + | Readonly<{ + kind: "truncated"; + reason: "cycle" | "depth" | "nodes"; + relation: string; + tuple: readonly OhProjectionAtomV1[]; + v: 1; + }>; + +export type OhMemoryLaneIdentityV1 = Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + datasetSha256: Sha256Hex; + head: OhHeadV1; + lane: OhMemoryLaneV1; + snapshotSha256: Sha256Hex; + v: 1; +}>; + +export type OhMemoryIdentityV1 = Readonly<{ + canonical: OhMemoryLaneIdentityV1; + compositeDatasetSha256: Sha256Hex; + conflictPolicy: typeof OH_MEMORY_CONFLICT_POLICY_V1; + evaluationSha256: Sha256Hex; + memorySha256: Sha256Hex; + programId: string; + projectionSha256: Sha256Hex; + purpose: string; + querySha256: Sha256Hex; + rulePackSha256: Sha256Hex; + v: 1; + working: OhMemoryLaneIdentityV1; +}>; + +export type OhMemoryConflictV1 = Readonly<{ + canonicalRecordSha256: Sha256Hex; + key: string; + v: 1; + workingRecordSha256: Sha256Hex; +}>; + +export type OhMemoryResultRowV1 = Readonly<{ + premiseAuthority: "canonical" | "unknown" | "working"; + premiseLanes: readonly OhMemoryLaneV1[]; + proofsTruncated: boolean; + resultRowSha256: Sha256Hex; + supportCount: number; + v: 1; + values: readonly OhProjectionAtomV1[]; +}>; + +export type OhMemoryQueryResultV1 = Readonly<{ + authority: "derived"; + conflicts: readonly OhMemoryConflictV1[]; + explainCapability: Readonly<{ expiresAt: string; token: string; v: 1 }>; + identity: OhMemoryIdentityV1; + projectionResultSha256: Sha256Hex; + resultSha256: Sha256Hex; + rows: readonly OhMemoryResultRowV1[]; + v: 1; +}>; + +export type OhMemoryRememberReceiptV1 = Readonly<{ + actorId: string; + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + instant: string; + lane: "working"; + operationSha256: Sha256Hex; + receiptSha256: Sha256Hex; + requestId: string; + status: "committed"; + v: 1; +}>; + +export type OhMemoryExplanationV1 = Readonly<{ + authority: "derived"; + explanationSha256: Sha256Hex; + identity: OhMemoryIdentityV1; + premiseAuthority: OhMemoryResultRowV1["premiseAuthority"]; + premiseLanes: readonly OhMemoryLaneV1[]; + proofs: readonly OhMemoryProofV1[]; + proofsTruncated: boolean; + resultRowSha256: Sha256Hex; + resultSha256: Sha256Hex; + supportCount: number; + v: 1; + values: readonly OhProjectionAtomV1[]; +}>; + +export type OhMemoryNominationV1 = Readonly<{ + closure: OhDependencyClosureV1; + destinationPurpose: string; + nominationId: string; + nominationSha256: Sha256Hex; + source: Readonly<{ + authorityId: string; + bindingSha256: Sha256Hex; + head: OhHeadV1; + lane: "working"; + v: 1; + }>; + status: "prepared"; + v: 1; +}>; + +export type OhMemoryNamedProgramV1 = Readonly<{ + evaluation?: OhProjectionEvaluationOptionsV1; + programId: string; + purpose: string; + query: OhProjectionQueryV1; + rulePack: OhProjectionRulePackV1; +}>; + +export type OhMemoryNominationRouteV1 = Readonly<{ + destinationPurpose: string; + nominationId: string; +}>; + +export type OhMemoryFactPolicyV1 = + | Readonly<{ + extractorSha256: Sha256Hex; + factPackId: string; + kind: "built-in"; + v: 1; + }> + | Readonly<{ + extractorId: string; + extractorSha256: Sha256Hex; + kind: "domain"; + v: 1; + }>; + +export type OhMemoryFactDeclarationV1 = Readonly<{ + relation: string; + tuple: readonly JsonPrimitive[]; + v: 1; +}>; + +/** Host-owned, digest-identified domain projection; it cannot choose sources. */ +export type OhMemoryFactExtractorV1 = Readonly<{ + extract(input: Readonly<{ + lane: OhMemoryLaneV1; + record: KnowledgeGraphRecordV1; + }>): readonly OhMemoryFactDeclarationV1[]; + extractorId: string; + extractorSha256: Sha256Hex; + relations: readonly string[]; +}>; + +export type OhMemoryFacadeOptionsV1 = Readonly<{ + actorId: string; + canonical: Readonly<{ + authorityId: string; + expectedBindingSha256: Sha256Hex; + expectedHead: OhHeadV1; + store: OhStoreV1; + }>; + explainCapabilityLifetimeMs?: number; + extractors?: readonly OhMemoryFactExtractorV1[]; + monotonicNow?: () => number; + nominationRoutes?: readonly OhMemoryNominationRouteV1[]; + now?: () => Date; + programs: readonly OhMemoryNamedProgramV1[]; + working: Readonly<{ + authorityId: string; + codecs: OhRecordCodecRegistry; + expectedBindingSha256: Sha256Hex; + store: OhStoreV1; + }>; +}>; + +export interface OhMemoryAgentV1 { + explain(value: unknown): Promise; + nominate(value: unknown): Promise; + query(value: unknown): Promise; + remember(value: unknown): Promise; +} + +type LaneSnapshot = Readonly<{ + authorityId: string; + binding: OhStoreBindingV1; + dataset: OhProjectionDatasetV1; + lane: OhMemoryLaneV1; + projectionSnapshot: OhProjectionSnapshotV1; + snapshot: OhSnapshotV1; +}>; + +type SyntheticSource = Readonly<{ + physical: OhMemoryAuthoritySourceV1; + record: KnowledgeGraphRecordV1; +}>; + +type StoredExplanation = Readonly<{ + bytes: number; + expiresAtMonotonicMs: number; + identity: OhMemoryIdentityV1; + proofs: readonly (readonly OhMemoryProofV1[])[]; + resultSha256: Sha256Hex; + rows: readonly OhMemoryResultRowV1[]; +}>; + +const builtInFactPolicy: OhMemoryFactPolicyV1 = Object.freeze({ + extractorSha256: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.extractorSha256, + factPackId: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackId, + kind: "built-in", + v: 1, +}); + +function immutableClone(value: T): T { + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => immutableClone(item))) as T; + } + if (value !== null && typeof value === "object") { + if (!isPlainRecord(value)) throw new TypeError("Memory output contains a non-JSON object."); + const cloned: Record = {}; + for (const key of Object.keys(value)) { + Object.defineProperty(cloned, key, { configurable: false, enumerable: true, + value: immutableClone(value[key]), writable: false }); + } + return Object.freeze(cloned) as T; + } + return value; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function exactHead(left: OhHeadV1, right: OhHeadV1): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +function authorityId(value: unknown): string { + const parsed = safeCode(value, 128); + if (parsed === null) throw new TypeError("Invalid memory authority ID."); + return parsed; +} + +function bindingFor(store: OhStoreV1, expected: Sha256Hex, lane: OhMemoryLaneV1): OhStoreBindingV1 { + const binding = parseOhStoreBindingV1(store.binding); + if (binding === null || binding.bindingSha256 !== parseSha256Hex(expected)) { + throw new OhIntegrityError(`The ${lane} store is not the host-bound authority.`); + } + if (binding.profile.profileKind !== lane) { + throw new OhProfileError(`The ${lane} memory lane has the wrong store profile.`); + } + return binding; +} + +function laneIdentity(value: LaneSnapshot): OhMemoryLaneIdentityV1 { + return Object.freeze({ + authorityId: value.authorityId, + bindingSha256: value.binding.bindingSha256, + datasetSha256: value.dataset.datasetSha256, + head: value.snapshot.head, + lane: value.lane, + snapshotSha256: value.projectionSnapshot.snapshotSha256, + v: 1, + }); +} + +function datasetForSnapshot(binding: OhStoreBindingV1, snapshot: OhSnapshotV1): Readonly<{ + dataset: OhProjectionDatasetV1; + projectionSnapshot: OhProjectionSnapshotV1; +}> { + const projectionSnapshot = createOhProjectionSnapshotV1({ + head: snapshot.head, + records: snapshot.records, + spaceId: binding.spaceId, + }); + const dataset = createOhProjectionDatasetV1({ + extractorSha256: canonicalSha256({ extractor: "oh.memory.lane-structural", v: 1 }), + factPackId: "oh.memory.lane-structural", + factPackRevision: 1, + facts: createOhProjectionRecordFactsV1(snapshot.records), + snapshot: projectionSnapshot, + }); + return { dataset, projectionSnapshot }; +} + +async function readLane( + authority: Readonly<{ authorityId: string; binding: OhStoreBindingV1; store: OhStoreV1 }>, + lane: OhMemoryLaneV1, + expectedHead?: OhHeadV1, +): Promise { + const returnedHead = expectedHead ?? await authority.store.head(); + const head = parseOhHeadV1(immutableClone(returnedHead)); + if (head === null) throw new OhIntegrityError(`The ${lane} store returned an invalid head.`); + const returnedSnapshot = await authority.store.snapshot({ + head: { operationSha256: head.operationSha256, sequence: head.sequence }, + maximumRecords: OH_MEMORY_LIMITS_V1.maximumRecordsPerLane, + }); + if (!isPlainRecord(returnedSnapshot) + || !hasExactKeys(returnedSnapshot, ["head", "records", "v"]) + || returnedSnapshot.v !== 1 || !Array.isArray(returnedSnapshot.records)) { + throw new OhIntegrityError(`The ${lane} store returned an invalid snapshot envelope.`); + } + const detached = immutableClone(returnedSnapshot); + const detachedHead = parseOhHeadV1(detached.head); + if (detachedHead === null) throw new OhIntegrityError(`The ${lane} store returned an invalid snapshot head.`); + const snapshot: OhSnapshotV1 = immutableClone({ head: detachedHead, + records: detached.records, v: 1 }); + if (!exactHead(snapshot.head, head)) { + throw new OhIntegrityError(`The ${lane} snapshot differs from its pinned head.`); + } + if (utf8ByteLength(canonicalJson(snapshot)) > OH_MEMORY_LIMITS_V1.snapshotBytesPerLane) { + throw new RangeError(`The ${lane} memory snapshot exceeds its canonical byte bound.`); + } + const projected = datasetForSnapshot(authority.binding, snapshot); + return Object.freeze({ authorityId: authority.authorityId, binding: authority.binding, + dataset: projected.dataset, lane, projectionSnapshot: projected.projectionSnapshot, snapshot }); +} + +function syntheticKey(lane: OhMemoryLaneV1, recordSha256: Sha256Hex): string { + return `memory-source:${lane}:${recordSha256}`; +} + +function createSyntheticSources(lanes: readonly LaneSnapshot[]): Readonly<{ + records: readonly KnowledgeGraphRecordV1[]; + sources: ReadonlyMap; +}> { + const sources = new Map(); + for (const lane of lanes) { + for (const physicalRecord of lane.snapshot.records) { + const key = syntheticKey(lane.lane, physicalRecord.recordSha256); + const record = createKnowledgeGraphRecordV1({ dependencies: [], key, kind: "view", v: 1, + value: { authorityId: lane.authorityId, bindingSha256: lane.binding.bindingSha256, + key: physicalRecord.key, lane: lane.lane, recordSha256: physicalRecord.recordSha256, + snapshotSha256: lane.projectionSnapshot.snapshotSha256, v: 1 } }); + const physical = Object.freeze({ authorityId: lane.authorityId, + bindingSha256: lane.binding.bindingSha256, + head: lane.snapshot.head, key: physicalRecord.key, lane: lane.lane, + recordSha256: physicalRecord.recordSha256, + snapshotSha256: lane.projectionSnapshot.snapshotSha256, v: 1 as const }); + if (sources.has(key)) throw new OhIntegrityError("A memory lane contains a duplicate source digest."); + sources.set(key, Object.freeze({ physical, record })); + } + } + if (sources.size > OH_MEMORY_LIMITS_V1.maximumSyntheticRecords) { + throw new RangeError("The composite memory snapshot has too many records."); + } + const records = [...sources.values()].map(({ record }) => record) + .sort((left, right) => compareText(left.key, right.key)); + return { records, sources }; +} + +function sourceFor( + sources: ReadonlyMap, + lane: OhMemoryLaneV1, + record: KnowledgeGraphRecordV1, +) { + const source = sources.get(syntheticKey(lane, record.recordSha256)); + if (source === undefined) throw new OhIntegrityError("A composite memory source is missing."); + return [{ key: source.record.key, recordSha256: source.record.recordSha256, v: 1 as const }]; +} + +function createCompositeDataset(canonical: LaneSnapshot, working: LaneSnapshot, + extractors: readonly OhMemoryFactExtractorV1[]): Readonly<{ + conflicts: readonly OhMemoryConflictV1[]; + dataset: OhProjectionDatasetV1; + factPolicies: ReadonlyMap; + snapshot: OhProjectionSnapshotV1; + sources: ReadonlyMap; +}> { + const synthetic = createSyntheticSources([canonical, working]); + const extractorInvocations = synthetic.records.length * extractors.length; + if (extractorInvocations > OH_MEMORY_LIMITS_V1.maximumExtractorInvocations) { + throw new RangeError("The composite memory extractor invocation count exceeds its explicit bound."); + } + const facts: OhProjectionFactV1[] = []; + const factDigests = new Set(); + const factPolicies = new Map(); + const addFact = (fact: OhProjectionFactV1, policy: OhMemoryFactPolicyV1) => { + if (facts.length >= OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("The composite memory fact set exceeds its explicit bound."); + } + if (factDigests.has(fact.factSha256)) { + throw new OhIntegrityError("A memory fact extractor emitted the same exact fact twice."); + } + const priorPolicy = factPolicies.get(fact.relation); + if (priorPolicy !== undefined && canonicalJson(priorPolicy) !== canonicalJson(policy)) { + throw new OhIntegrityError("A memory relation has more than one fact policy."); + } + facts.push(fact); + factDigests.add(fact.factSha256); + factPolicies.set(fact.relation, policy); + }; + const byLane = new Map>([ + ["canonical", new Map(canonical.snapshot.records.map((record) => [record.key, record]))], + ["working", new Map(working.snapshot.records.map((record) => [record.key, record]))], + ]); + for (const lane of [canonical, working] as const) { + for (const record of lane.snapshot.records) { + const extractorRecord = immutableClone(record); + const source = sourceFor(synthetic.sources, lane.lane, record); + addFact(createOhProjectionFactV1({ relation: "memory.record", sources: source, + tuple: [lane.lane, record.key, record.kind, record.recordSha256] }), builtInFactPolicy); + for (const dependency of record.dependencies) { + addFact(createOhProjectionFactV1({ relation: "memory.dependency", sources: source, + tuple: [lane.lane, record.key, dependency] }), builtInFactPolicy); + } + for (const extractor of extractors) { + const declared = extractor.extract(Object.freeze({ lane: lane.lane, record: extractorRecord })); + if (!Array.isArray(declared) + || declared.length > OH_MEMORY_LIMITS_V1.factsPerRecordPerExtractor) { + throw new RangeError("A memory fact extractor exceeded its per-record bound."); + } + for (const fact of declared) { + if (!isPlainRecord(fact) || !hasExactKeys(fact, ["relation", "tuple", "v"]) + || fact.v !== 1 || !Array.isArray(fact.tuple) || typeof fact.relation !== "string" + || !extractor.relations.includes(fact.relation)) { + throw new TypeError("A memory fact extractor returned an invalid or reserved fact."); + } + addFact(createOhProjectionFactV1({ relation: fact.relation, sources: source, tuple: fact.tuple }), + Object.freeze({ extractorId: extractor.extractorId, + extractorSha256: extractor.extractorSha256, kind: "domain", v: 1 })); + } + } + } + } + const conflicts: OhMemoryConflictV1[] = []; + const canonicalByKey = byLane.get("canonical")!; + const workingByKey = byLane.get("working")!; + for (const key of [...canonicalByKey.keys()].filter((candidate) => workingByKey.has(candidate)).sort()) { + const canonicalRecord = canonicalByKey.get(key)!; + const workingRecord = workingByKey.get(key)!; + const sources = [ + ...sourceFor(synthetic.sources, "canonical", canonicalRecord), + ...sourceFor(synthetic.sources, "working", workingRecord), + ]; + if (canonicalRecord.recordSha256 === workingRecord.recordSha256) { + addFact(createOhProjectionFactV1({ relation: "memory.agreement", sources, + tuple: [key, canonicalRecord.recordSha256] }), builtInFactPolicy); + } else { + addFact(createOhProjectionFactV1({ relation: "memory.conflict", sources, + tuple: [key, canonicalRecord.recordSha256, workingRecord.recordSha256] }), builtInFactPolicy); + conflicts.push(Object.freeze({ canonicalRecordSha256: canonicalRecord.recordSha256, + key, v: 1, workingRecordSha256: workingRecord.recordSha256 })); + } + } + const recordRefs = synthetic.records.map(knowledgeGraphRecordRefV1) + .sort((left, right) => compareText(left.key, right.key)); + const sourceIdentity = { + canonical: laneIdentity(canonical), + conflictPolicy: OH_MEMORY_CONFLICT_POLICY_V1, + recordRefs, + v: 1 as const, + working: laneIdentity(working), + }; + const head: OhHeadV1 = Object.freeze({ generation: 1, + graphRevisionSha256: canonicalSha256({ kind: "oh.memory.composite-graph", sourceIdentity }), + operationSha256: canonicalSha256({ kind: "oh.memory.composite-operation", sourceIdentity }), + recordsSha256: canonicalSha256(recordRefs), sequence: 1, v: 1 }); + const snapshot = createOhProjectionSnapshotV1({ head, records: synthetic.records, + spaceId: "oh.memory.composite" }); + const dataset = createOhProjectionDatasetV1({ + extractorSha256: canonicalSha256({ + builtIn: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.extractorSha256, + extensions: extractors.map(({ extractorId, extractorSha256, relations }) => ({ + extractorId, extractorSha256, relations, + })), + v: 1, + }), + factPackId: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackId, + factPackRevision: OH_MEMORY_COMPOSITE_FACT_EXTRACTOR_V1.factPackRevision, + facts, + snapshot, + }); + return Object.freeze({ conflicts: Object.freeze(conflicts), dataset, factPolicies, snapshot, + sources: synthetic.sources }); +} + +function mapProof(proof: OhProjectionProofV1, + sources: ReadonlyMap, + factPolicies: ReadonlyMap): OhMemoryProofV1 { + if (proof.kind === "truncated") return Object.freeze({ ...proof }); + if (proof.kind === "derived") { + return Object.freeze({ ...proof, + premises: Object.freeze(proof.premises.map((premise) => mapProof(premise, sources, factPolicies))) }); + } + const physical = proof.sources.map((source) => { + const mapped = sources.get(source.key); + if (mapped === undefined || mapped.record.recordSha256 !== source.recordSha256) { + throw new OhIntegrityError("A projection proof has no exact physical memory source."); + } + return mapped.physical; + }).sort((left, right) => compareText(canonicalJson(left), canonicalJson(right))); + const factPolicy = factPolicies.get(proof.relation); + if (factPolicy === undefined) throw new OhIntegrityError("A projection proof has no memory fact policy."); + return Object.freeze({ factPolicy, kind: "fact", relation: proof.relation, + sources: Object.freeze(physical), tuple: proof.tuple, v: 1 }); +} + +function collectLanes(proof: OhMemoryProofV1, lanes: Set): boolean { + if (proof.kind === "truncated") return true; + if (proof.kind === "fact") { + for (const source of proof.sources) lanes.add(source.lane); + return false; + } + let unknown = proof.premisesTruncated; + for (const premise of proof.premises) unknown = collectLanes(premise, lanes) || unknown; + return unknown; +} + +function publicRow(row: OhProjectionResultRowV1, + proofs: readonly OhMemoryProofV1[]): OhMemoryResultRowV1 { + const lanes = new Set(); + let unknown = row.proofsTruncated; + for (const proof of proofs) unknown = collectLanes(proof, lanes) || unknown; + const premiseLanes = [...lanes].sort() as readonly OhMemoryLaneV1[]; + const premiseAuthority: OhMemoryResultRowV1["premiseAuthority"] = unknown || premiseLanes.length === 0 + ? "unknown" : premiseLanes.includes("working") ? "working" : "canonical"; + const payload = { premiseAuthority, premiseLanes, proofsTruncated: row.proofsTruncated, + supportCount: row.supportCount, v: 1 as const, values: row.values }; + return Object.freeze({ ...payload, resultRowSha256: canonicalSha256(payload) }); +} + +function resolvePrograms(programs: readonly OhMemoryNamedProgramV1[]): ReadonlyMap { + if (programs.length < 1 || programs.length > OH_MEMORY_LIMITS_V1.maximumPrograms) { + throw new RangeError("Memory requires a bounded nonempty named program registry."); + } + const resolved = new Map(); + for (const program of programs) { + const programId = safeCode(program.programId, 128); + const purpose = safeCode(program.purpose, 256); + const query = parseOhProjectionQueryV1(program.query); + const rulePack = parseOhProjectionRulePackV1(program.rulePack); + if (programId === null || purpose === null || query === null || rulePack === null + || resolved.has(programId)) { + throw new TypeError("Invalid or duplicate named memory program."); + } + resolved.set(programId, immutableClone({ ...(program.evaluation === undefined ? {} + : { evaluation: { ...program.evaluation } }), programId, purpose, query, rulePack })); + } + return resolved; +} + +function resolveExtractors(extractors: readonly OhMemoryFactExtractorV1[]): readonly OhMemoryFactExtractorV1[] { + if (extractors.length > OH_MEMORY_LIMITS_V1.maximumExtractors) { + throw new RangeError("The memory domain extractor registry is too large."); + } + const claimedRelations = new Set(); + const resolved = extractors.map((extractor) => { + const extractorId = safeCode(extractor.extractorId, 128); + const extractorSha256 = parseSha256Hex(extractor.extractorSha256); + if (extractorId === null || extractorSha256 === null || typeof extractor.extract !== "function" + || !Array.isArray(extractor.relations) || extractor.relations.length < 1 + || extractor.relations.length > OH_MEMORY_LIMITS_V1.relationsPerExtractor) { + throw new TypeError("Invalid memory domain fact extractor."); + } + const relations = extractor.relations.map((relation) => safeCode(relation, 128)).sort(); + if (relations.some((relation) => relation === null + || relation.startsWith("memory.") || relation.startsWith("oh.")) + || new Set(relations).size !== relations.length) { + throw new TypeError("A memory domain fact extractor has invalid or reserved relations."); + } + for (const relation of relations as string[]) { + if (claimedRelations.has(relation)) { + throw new TypeError("Memory domain fact extractor relations must have one owner."); + } + claimedRelations.add(relation); + } + return Object.freeze({ extract: extractor.extract, extractorId, extractorSha256, + relations: Object.freeze(relations as string[]) }); + }).sort((left, right) => compareText(left.extractorId, right.extractorId)); + if (new Set(resolved.map(({ extractorId }) => extractorId)).size !== resolved.length) { + throw new TypeError("Duplicate memory domain fact extractor ID."); + } + return Object.freeze(resolved); +} + +function resolveNominationRoutes(routes: readonly OhMemoryNominationRouteV1[]): +ReadonlyMap { + if (routes.length > OH_MEMORY_LIMITS_V1.maximumNominationRoutes) { + throw new RangeError("The memory nomination route registry is too large."); + } + const resolved = new Map(); + for (const route of routes) { + const nominationId = safeCode(route.nominationId, 128); + const destinationPurpose = safeCode(route.destinationPurpose, 256); + if (nominationId === null || destinationPurpose === null || resolved.has(nominationId)) { + throw new TypeError("Invalid or duplicate memory nomination route."); + } + resolved.set(nominationId, Object.freeze({ destinationPurpose, nominationId })); + } + return resolved; +} + +function parseQueryRequest(value: unknown): Readonly<{ programId: string }> { + if (!isPlainRecord(value) || !hasExactKeys(value, ["programId", "v"]) + || value.v !== 1) throw new TypeError("Invalid named memory query."); + const programId = safeCode(value.programId, 128); + if (programId === null) throw new TypeError("Invalid named memory query identity."); + return { programId }; +} + +function parseExplainRequest(value: unknown): Readonly<{ + row: number; + resultSha256: Sha256Hex; + token: string; +}> { + if (!isPlainRecord(value) || !hasExactKeys(value, ["resultSha256", "row", "token", "v"]) + || value.v !== 1 || typeof value.token !== "string" || value.token.length !== 43 + || !Number.isSafeInteger(value.row) || (value.row as number) < 0) { + throw new TypeError("Invalid memory explanation request."); + } + const resultSha256 = parseSha256Hex(value.resultSha256); + if (resultSha256 === null) throw new TypeError("Invalid memory explanation result identity."); + return { resultSha256, row: value.row as number, token: value.token }; +} + +function parseNominationRequest(value: unknown): Readonly<{ + nominationId: string; + roots: readonly string[]; +}> { + if (!isPlainRecord(value) || !hasExactKeys(value, ["nominationId", "roots", "v"]) + || value.v !== 1 || !Array.isArray(value.roots) || value.roots.length < 1 + || value.roots.length > OH_DEPENDENCY_CLOSURE_LIMITS_V1.roots) { + throw new TypeError("Invalid memory nomination request."); + } + const nominationId = safeCode(value.nominationId, 128); + const roots = value.roots.map((root) => safeCode(root, 512)).sort(); + if (nominationId === null || roots.some((root) => root === null) + || new Set(roots).size !== roots.length) throw new TypeError("Invalid memory nomination identity."); + return { nominationId, roots: roots as readonly string[] }; +} + +function isoInstant(date: Date): string { + const value = date.toISOString(); + if (parseCanonicalInstantV1(value) === null) throw new TypeError("The memory clock returned an invalid instant."); + return value; +} + +function clockMilliseconds(now: () => Date): number { + const milliseconds = now().getTime(); + if (!Number.isFinite(milliseconds)) throw new TypeError("The memory clock returned an invalid date."); + return milliseconds; +} + +function monotonicMilliseconds(now: () => number): number { + const milliseconds = now(); + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new TypeError("The memory monotonic clock returned an invalid value."); + } + return milliseconds; +} + +/** + * Creates a model-facing memory surface over two host-bound physical Oh + * authorities. The returned object has no store, locator, rule, sync, canonical + * write, or purge handle. + */ +export async function createOhMemoryAgentV1(options: OhMemoryFacadeOptionsV1): Promise { + const memoryActorId = safeCode(options.actorId, 128); + if (memoryActorId === null) throw new TypeError("Invalid host-bound memory actor ID."); + const canonicalStore = options.canonical.store; + const workingStore = options.working.store; + const workingCodecs = options.working.codecs; + const canonicalAuthorityId = authorityId(options.canonical.authorityId); + const workingAuthorityId = authorityId(options.working.authorityId); + if (canonicalAuthorityId === workingAuthorityId) { + throw new OhProfileError("Working and canonical memory must be distinct physical authorities."); + } + const canonicalBinding = bindingFor(canonicalStore, + options.canonical.expectedBindingSha256, "canonical"); + const workingBinding = bindingFor(workingStore, + options.working.expectedBindingSha256, "working"); + const expectedCanonicalHead = parseOhHeadV1(options.canonical.expectedHead); + if (expectedCanonicalHead === null) throw new TypeError("Invalid pinned canonical memory head."); + const programs = resolvePrograms(options.programs); + const extractors = resolveExtractors(options.extractors ?? []); + const nominationRoutes = resolveNominationRoutes(options.nominationRoutes ?? []); + const ingress = new OhSemanticBundleIngressV1(workingStore, workingCodecs); + const now = options.now ?? (() => new Date()); + const monotonicNow = options.monotonicNow ?? (() => performance.now()); + const capabilityLifetime = options.explainCapabilityLifetimeMs + ?? OH_MEMORY_LIMITS_V1.explainCapabilityLifetimeMs; + if (!Number.isSafeInteger(capabilityLifetime) || capabilityLifetime < 1_000 + || capabilityLifetime > 60 * 60 * 1_000) { + throw new RangeError("Invalid memory explanation capability lifetime."); + } + const canonical = await readLane({ authorityId: canonicalAuthorityId, + binding: canonicalBinding, store: canonicalStore }, "canonical", expectedCanonicalHead); + const explanations = new Map(); + let explanationBytes = 0; + let lastMonotonicMs = -1; + let lastWallClockMs = Number.NEGATIVE_INFINITY; + const wallClock = () => { + const milliseconds = clockMilliseconds(now); + if (milliseconds < lastWallClockMs) throw new OhProfileError("The memory wall clock regressed."); + lastWallClockMs = milliseconds; + return milliseconds; + }; + const monotonicClock = () => { + const milliseconds = monotonicMilliseconds(monotonicNow); + if (milliseconds < lastMonotonicMs) throw new OhProfileError("The memory monotonic clock regressed."); + lastMonotonicMs = milliseconds; + return milliseconds; + }; + const deleteExplanation = (token: string) => { + const stored = explanations.get(token); + if (stored !== undefined && explanations.delete(token)) explanationBytes -= stored.bytes; + }; + + const remember = async (value: unknown): Promise => { + if (utf8ByteLength(canonicalJson(value)) > OH_MEMORY_LIMITS_V1.rememberBytes) { + throw new RangeError("The memory semantic bundle exceeds its canonical byte bound."); + } + if (!isPlainRecord(value) || !hasExactKeys(value, + ["expectedHead", "puts", "requestId", "tombstones", "v"]) || value.v !== 1) { + throw new TypeError("Invalid memory remember request."); + } + const requestId = safeCode(value.requestId, 128); + if (requestId === null) throw new TypeError("Invalid memory remember request identity."); + const operationId = `memory_${canonicalSha256({ actorId: memoryActorId, + bindingSha256: workingBinding.bindingSha256, requestId, v: 1 }).slice(0, 48)}`; + const operation = await ingress.commit({ actorId: memoryActorId, + expectedHead: value.expectedHead, instant: isoInstant(new Date(wallClock())), operationId, + puts: value.puts, tombstones: value.tombstones, v: 1 }); + const head: OhHeadV1 = { generation: operation.sequence, + graphRevisionSha256: operation.graphRevisionSha256, + operationSha256: operation.operationSha256, recordsSha256: operation.recordsSha256, + sequence: operation.sequence, v: 1 }; + const payload = { actorId: operation.actorId, authorityId: workingAuthorityId, + bindingSha256: workingBinding.bindingSha256, head, instant: operation.instant, + lane: "working" as const, operationSha256: operation.operationSha256, requestId, + status: "committed" as const, v: 1 as const }; + return immutableClone({ ...payload, receiptSha256: canonicalSha256(payload) }); + }; + + const query = async (value: unknown): Promise => { + const request = parseQueryRequest(value); + const program = programs.get(request.programId); + if (program === undefined) throw new TypeError("Unknown named memory program."); + const working = await readLane({ authorityId: workingAuthorityId, + binding: workingBinding, store: workingStore }, "working"); + const composite = createCompositeDataset(canonical, working, extractors); + const projection = evaluateOhProjectionV1({ dataset: composite.dataset, + ...(program.evaluation === undefined ? {} : { options: program.evaluation }), + query: program.query, rulePack: program.rulePack, snapshot: composite.snapshot }); + const identityPayload = { + canonical: laneIdentity(canonical), + compositeDatasetSha256: composite.dataset.datasetSha256, + conflictPolicy: OH_MEMORY_CONFLICT_POLICY_V1, + evaluationSha256: projection.identity.evaluationSha256, + programId: program.programId, + projectionSha256: projection.identity.projectionSha256, + purpose: program.purpose, + querySha256: program.query.querySha256, + rulePackSha256: program.rulePack.rulePackSha256, + v: 1 as const, + working: laneIdentity(working), + }; + const identity: OhMemoryIdentityV1 = immutableClone({ ...identityPayload, + memorySha256: canonicalSha256(identityPayload) }); + const proofs = immutableClone(projection.rows.map((row) => + row.proofs.map((proof) => mapProof(proof, composite.sources, composite.factPolicies)))); + const rows = immutableClone(projection.rows.map((row, index) => publicRow(row, proofs[index]!))); + const resultPayload = immutableClone({ authority: "derived" as const, + conflicts: composite.conflicts, identity, projectionResultSha256: projection.resultSha256, + rows, v: 1 as const }); + const resultSha256 = canonicalSha256(resultPayload); + const issuedAt = wallClock(); + const issuedAtMonotonic = monotonicClock(); + const expiresAtMs = issuedAt + capabilityLifetime; + const expiresAtMonotonicMs = issuedAtMonotonic + capabilityLifetime; + const expiresAt = isoInstant(new Date(expiresAtMs)); + for (const [existingToken, stored] of explanations) { + if (issuedAtMonotonic >= stored.expiresAtMonotonicMs) deleteExplanation(existingToken); + } + const storedPayload = immutableClone({ expiresAtMonotonicMs, identity, proofs, resultSha256, rows }); + const storedBytes = utf8ByteLength(canonicalJson(storedPayload)) + 128; + if (storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityEntryBytes + || storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityTotalBytes) { + throw new RangeError("The memory explanation exceeds its retained capability bound."); + } + while (explanations.size >= OH_MEMORY_LIMITS_V1.explainCapabilities + || explanationBytes + storedBytes > OH_MEMORY_LIMITS_V1.explainCapabilityTotalBytes) { + const oldest = explanations.keys().next().value as string | undefined; + if (oldest === undefined) break; + deleteExplanation(oldest); + } + let token = randomBytes(32).toString("base64url"); + while (explanations.has(token)) token = randomBytes(32).toString("base64url"); + explanations.set(token, immutableClone({ ...storedPayload, bytes: storedBytes })); + explanationBytes += storedBytes; + const result = immutableClone({ ...resultPayload, + explainCapability: { expiresAt, token, v: 1 as const }, resultSha256 }); + if (utf8ByteLength(canonicalJson(result)) > OH_MEMORY_LIMITS_V1.resultBytes) { + deleteExplanation(token); + throw new RangeError("The composite memory result exceeds its canonical byte bound."); + } + return result; + }; + + const explain = async (value: unknown): Promise => { + const request = parseExplainRequest(value); + const stored = explanations.get(request.token); + const currentTime = monotonicClock(); + if (stored === undefined || stored.resultSha256 !== request.resultSha256 + || currentTime >= stored.expiresAtMonotonicMs) { + deleteExplanation(request.token); + throw new OhProfileError("The memory explanation capability is absent, expired, or misbound."); + } + const row = stored.rows[request.row]; + const proofs = stored.proofs[request.row]; + if (row === undefined || proofs === undefined) throw new RangeError("The explanation row is out of bounds."); + const payload = { authority: "derived" as const, identity: stored.identity, + premiseAuthority: row.premiseAuthority, premiseLanes: row.premiseLanes, proofs, + proofsTruncated: row.proofsTruncated, resultRowSha256: row.resultRowSha256, + resultSha256: stored.resultSha256, supportCount: row.supportCount, v: 1 as const, + values: row.values }; + return immutableClone({ ...payload, explanationSha256: canonicalSha256(payload) }); + }; + + const nominate = async (value: unknown): Promise => { + const request = parseNominationRequest(value); + const route = nominationRoutes.get(request.nominationId); + if (route === undefined) throw new TypeError("Unknown named memory nomination route."); + const head = parseOhHeadV1(immutableClone(await workingStore.head())); + if (head === null) throw new OhIntegrityError("The working nomination store returned an invalid head."); + const closure = await workingStore.exportDependencyClosure({ head: { + operationSha256: head.operationSha256, sequence: head.sequence }, roots: request.roots }); + const verified = verifyOhDependencyClosureAgainstV1(closure, { binding: workingBinding, head }); + if (!verified.ok) throw new OhIntegrityError("The working nomination closure failed exact verification."); + if (canonicalJson(verified.closure.roots) !== canonicalJson(request.roots)) { + throw new OhIntegrityError("The working nomination closure substituted different roots."); + } + const source = Object.freeze({ authorityId: workingAuthorityId, + bindingSha256: workingBinding.bindingSha256, head, lane: "working" as const, v: 1 as const }); + const payload = { closure: verified.closure, destinationPurpose: route.destinationPurpose, + nominationId: route.nominationId, source, status: "prepared" as const, v: 1 as const }; + return immutableClone({ ...payload, nominationSha256: canonicalSha256(payload) }); + }; + + return Object.freeze({ explain, nominate, query, remember }); +} diff --git a/src/projection-public.ts b/src/projection-public.ts index ea0720b..812b093 100644 --- a/src/projection-public.ts +++ b/src/projection-public.ts @@ -23,7 +23,9 @@ export const parseOhProjectionDatasetV1 = Projection.parseOhProjectionDatasetV1; export const parseOhProjectionFactV1 = Projection.parseOhProjectionFactV1; export const parseOhProjectionIdentityV1 = Projection.parseOhProjectionIdentityV1; export const parseOhProjectionLiteralV1 = Projection.parseOhProjectionLiteralV1; +export const parseOhProjectionProofV1 = Projection.parseOhProjectionProofV1; export const parseOhProjectionQueryV1 = Projection.parseOhProjectionQueryV1; +export const parseOhProjectionResultV1 = Projection.parseOhProjectionResultV1; export const parseOhProjectionRulePackV1 = Projection.parseOhProjectionRulePackV1; export const parseOhProjectionRuleV1 = Projection.parseOhProjectionRuleV1; export const parseOhProjectionSnapshotV1 = Projection.parseOhProjectionSnapshotV1; diff --git a/src/projection-suss.ts b/src/projection-suss.ts index 170ca90..1982773 100644 --- a/src/projection-suss.ts +++ b/src/projection-suss.ts @@ -76,10 +76,16 @@ function assertConservativeOutputBound(input: Readonly<{ const domainSize = BigInt(projectionDomainSize(input.dataset, input.rulePack, input.query)); const heads = new Map(); for (const item of input.rulePack.rules) heads.set(item.head.relation, item.head.terms.length); + const baseCounts = new Map(); + for (const fact of input.dataset.facts) { + if (heads.has(fact.relation)) baseCounts.set(fact.relation, (baseCounts.get(fact.relation) ?? 0) + 1); + } let possible = 0n; - const maximum = BigInt(input.maximumDerivedTuples + input.dataset.facts.length); - for (const arity of heads.values()) { - possible += domainSize ** BigInt(arity); + const maximum = BigInt(input.maximumDerivedTuples); + for (const [relation, arity] of heads) { + const relationSpace = domainSize ** BigInt(arity); + const existing = BigInt(baseCounts.get(relation) ?? 0); + possible += relationSpace > existing ? relationSpace - existing : 0n; if (possible > maximum) { throw new RangeError("The Suss equivalence adapter cannot prove the requested derived-tuple bound before evaluation; use the bounded Oh evaluator."); } diff --git a/src/projection.test.ts b/src/projection.test.ts index e0aad9f..40c031d 100644 --- a/src/projection.test.ts +++ b/src/projection.test.ts @@ -15,16 +15,20 @@ import { createOhProjectionSnapshotV1, evaluateOhProjectionV1, invalidationForOhProjectionV1, + OH_PROJECTION_LIMITS_V1, OH_PROJECTION_RECORD_FACT_EXTRACTOR_V1, ohProjectionConstantV1, ohProjectionVariableV1, - parseOhProjectionQueryV1, parseOhProjectionIdentityV1, + parseOhProjectionProofV1, + parseOhProjectionQueryV1, + parseOhProjectionResultV1, parseOhProjectionSnapshotV1, type OhProjectionAtomV1, type OhProjectionDatasetV1, type OhProjectionProofV1, type OhProjectionQueryV1, + type OhProjectionResultV1, type OhProjectionRulePackV1, type OhProjectionSnapshotV1, } from "./projection"; @@ -107,6 +111,21 @@ function expectedClosure(edges: readonly (readonly [string, string])[]): readonl return [...reachable].sort().map((value) => JSON.parse(value) as OhProjectionAtomV1[]); } +function resignProjectionResult(value: Readonly>): Readonly> { + const { resultSha256: _resultSha256, ...payload } = value; + return { ...payload, resultSha256: canonicalSha256(payload) }; +} + +function resultWithEvaluation(result: OhProjectionResultV1, + evaluation: OhProjectionResultV1["evaluation"]): Readonly> { + const { projectionSha256: _projectionSha256, ...identityPayload } = { + ...result.identity, + evaluationSha256: canonicalSha256(evaluation), + }; + const identity = { ...identityPayload, projectionSha256: canonicalSha256(identityPayload) }; + return resignProjectionResult({ ...result, evaluation, identity }); +} + describe("projection identity and validation", () => { test("binds facts to an exact graph head without changing V1 graph bytes", () => { const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; @@ -194,6 +213,73 @@ describe("projection identity and validation", () => { rulePack: rules, snapshot: firstSnapshot }); expect(invalidationForOhProjectionV1(first, third)).toEqual({ kind: "full-rebuild", reasons: ["query-changed"], v: 1 }); + const fourth = createOhProjectionIdentityV1({ dataset: firstDataset, + options: { maximumProofNodes: 1 }, query, rulePack: rules, snapshot: firstSnapshot }); + expect(invalidationForOhProjectionV1(first, fourth)).toEqual({ kind: "full-rebuild", + reasons: ["evaluation-changed"], v: 1 }); + }); +}); + +describe("cached projection ingress", () => { + test("round-trips complete result and proof envelopes", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact), + query: allPathsQuery(), rulePack: reachabilityRules(), snapshot: exact }); + expect(parseOhProjectionResultV1(result)).toEqual(result); + expect(parseOhProjectionResultV1(result, result.identity.projectionSha256)).toEqual(result); + expect(parseOhProjectionResultV1(result, "f".repeat(64) as Sha256Hex)).toBeNull(); + const proof = result.rows[0]?.proofs[0] as OhProjectionProofV1; + expect(parseOhProjectionProofV1(proof)).toEqual(proof); + }); + + test("rejects cache tampering even when an attacker recomputes the outer digest", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact), + query: allPathsQuery(), rulePack: reachabilityRules(), snapshot: exact }); + expect(parseOhProjectionResultV1({ ...result, resultSha256: "f".repeat(64) })).toBeNull(); + const first = result.rows[0] as OhProjectionResultV1["rows"][number]; + const rows = [{ ...first, supportCount: first.supportCount + 1 }, ...result.rows.slice(1)]; + expect(parseOhProjectionResultV1(resignProjectionResult({ ...result, rows }))).toBeNull(); + expect(parseOhProjectionResultV1(resignProjectionResult({ ...result, + engine: "oh.attacker.engine.v1" }))).toBeNull(); + }); + + test("applies declared per-row proof limits and public aggregate ceilings on ingress", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + const query = createOhProjectionQueryV1({ find: ["z"], queryId: "paths.from-a", + where: [createOhProjectionLiteralV1({ relation: "path", terms: [c("a"), v("z")] })] }); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact), query, + rulePack: reachabilityRules(), snapshot: exact }); + const smallerEvaluation = { ...result.evaluation, maximumProofNodes: 1 }; + expect(parseOhProjectionResultV1(resultWithEvaluation(result, smallerEvaluation))).toBeNull(); + expect(parseOhProjectionResultV1(resignProjectionResult({ ...result, + stats: { ...result.stats, proofNodes: OH_PROJECTION_LIMITS_V1.totalProofNodes + 1 } }))).toBeNull(); + expect(parseOhProjectionResultV1(resignProjectionResult({ ...result, + evaluation: { ...result.evaluation, maximumWorkUnits: OH_PROJECTION_LIMITS_V1.workUnits + 1 } }))) + .toBeNull(); + }); + + test("rejects malformed and over-depth standalone proof trees", () => { + const digest = "a".repeat(64) as Sha256Hex; + const malformed = { kind: "fact", relation: "edge", + sources: [{ key: "view:one", recordSha256: digest, v: 1 }], tuple: ["a", "b"], + unexpected: true, v: 1 }; + expect(parseOhProjectionProofV1(malformed)).toBeNull(); + expect(parseOhProjectionProofV1({ kind: "fact", relation: "edge", + sources: [{ key: "view:one", recordSha256: digest, v: 1 }, + { key: "view:one", recordSha256: "b".repeat(64), v: 1 }], + tuple: ["a", "b"], v: 1 })).toBeNull(); + + let proof: unknown = { kind: "fact", relation: "edge", + sources: [{ key: "view:one", recordSha256: digest, v: 1 }], tuple: ["a", "b"], v: 1 }; + for (let depth = 0; depth <= OH_PROJECTION_LIMITS_V1.proofDepth; depth += 1) { + proof = { kind: "derived", premises: [proof], premisesTruncated: false, + relation: "path", ruleId: "path.deep", ruleSha256: digest, tuple: ["a", "b"], v: 1 }; + } + expect(parseOhProjectionProofV1(proof)).toBeNull(); }); }); @@ -226,6 +312,38 @@ describe("positive recursive evaluation", () => { where: [literal("path", v("x"))] }); expect(() => evaluateOhProjectionV1({ dataset: dataset(records, exact), query: badQuery, rulePack: reachabilityRules(), snapshot: exact })).toThrow("conflicting arities"); + expect(() => evaluateOhProjectionV1({ dataset: dataset(records, exact), + options: { maximumWorkUnits: 1 }, query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact })).toThrow("work-unit bound"); + }); + + test("marks proof-prefix exhaustion at both the row and result level", () => { + const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact), + options: { maximumProofNodes: 1 }, query: createOhProjectionQueryV1({ find: ["x", "z"], + queryId: "two.edges", where: [literal("edge", v("x"), v("y")), + literal("edge", v("y"), v("z"))] }), rulePack: reachabilityRules(), snapshot: exact }); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ proofsTruncated: true, values: ["a", "c"] }); + expect(result.rows[0]?.proofs).toHaveLength(1); + expect(result.stats.proofsTruncated).toBe(true); + expect(parseOhProjectionResultV1(result)).toEqual(result); + }); + + test("bounds proofs across rows and reports collapsed alternative support", () => { + const records = [edgeRecord("a", "b"), edgeRecord("a", "c"), edgeRecord("d", "e")]; + const exact = snapshot(records); + const result = evaluateOhProjectionV1({ dataset: dataset(records, exact), + options: { maximumTotalProofNodes: 1 }, query: createOhProjectionQueryV1({ find: ["x"], + queryId: "edge.sources", where: [literal("edge", v("x"), v("y"))] }), + rulePack: reachabilityRules(), snapshot: exact }); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toMatchObject({ proofsTruncated: false, supportCount: 2, values: ["a"] }); + expect(result.rows[1]).toMatchObject({ proofs: [], proofsTruncated: true, + supportCount: 1, values: ["d"] }); + expect(result.stats).toMatchObject({ proofNodes: 1, proofsTruncated: true }); + expect(parseOhProjectionResultV1(result)).toEqual(result); }); test("matches graph reachability across generated input orders", () => { @@ -271,16 +389,30 @@ describe("optional Suss equivalence adapter", () => { const internal = evaluateOhProjectionV1(input); const external = evaluateOhProjectionWithSussV1(input); expect(external.engine).toBe(OH_PROJECTION_SUSS_ENGINE_V1); - expect(external.identity).toEqual(internal.identity); + expect(external.identity).toMatchObject({ datasetSha256: internal.identity.datasetSha256, + evaluationSha256: internal.identity.evaluationSha256, + querySha256: internal.identity.querySha256, rulePackSha256: internal.identity.rulePackSha256, + snapshotSha256: internal.identity.snapshotSha256 }); + expect(invalidationForOhProjectionV1(internal.identity, external.identity)).toEqual({ + kind: "full-rebuild", reasons: ["engine-changed"], v: 1 }); expect(external.rows).toEqual(internal.rows); expect(external.stats).toEqual(internal.stats); + expect(parseOhProjectionResultV1(external)).toEqual(external); }); test("rejects programs Suss cannot prove within the requested bound", () => { + const single = [edgeRecord("a", "b")]; + const singleSnapshot = snapshot(single); + const direct = createOhProjectionRulePackV1({ rulePackId: "test.direct", rulePackRevision: 1, + rules: [createOhProjectionRuleV1({ body: [literal("edge", v("x"), v("y"))], + head: literal("path", v("x"), v("y")), ruleId: "path.direct" })] }); + expect(() => evaluateOhProjectionWithSussV1({ dataset: dataset(single, singleSnapshot), + options: { maximumDerivedTuples: 1 }, query: allPathsQuery(), + rulePack: direct, snapshot: singleSnapshot })).toThrow("cannot prove"); const records = [edgeRecord("a", "b"), edgeRecord("b", "c")]; const exact = snapshot(records); expect(() => evaluateOhProjectionWithSussV1({ dataset: dataset(records, exact), - options: { maximumDerivedTuples: 1 }, query: allPathsQuery(), - rulePack: reachabilityRules(), snapshot: exact })).toThrow("cannot prove"); + options: { maximumRounds: 1 }, query: allPathsQuery(), + rulePack: reachabilityRules(), snapshot: exact })).toThrow("evaluation round bound"); }); }); diff --git a/src/projection.ts b/src/projection.ts index 968dbef..f565ced 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -6,6 +6,7 @@ import { orderedUnique, parseSha256Hex, safeCode, + sha256Hex, sortUnique, utf8ByteLength, type JsonPrimitive, @@ -38,10 +39,13 @@ export const OH_PROJECTION_LIMITS_V1 = Object.freeze({ queryMatches: 262_144, queryResults: 65_536, relations: 4_096, + resultBytes: 16 * 1024 * 1024, rounds: 1_024, rules: 1_024, sourcesPerFact: 64, + totalProofNodes: 65_536, variables: 256, + workUnits: 16_777_216, }); export type OhProjectionAtomV1 = JsonPrimitive; @@ -138,6 +142,8 @@ export type OhProjectionQueryV1 = Readonly<{ export type OhProjectionIdentityV1 = Readonly<{ contractSha256: Sha256Hex; datasetSha256: Sha256Hex; + engineSha256: Sha256Hex; + evaluationSha256: Sha256Hex; projectionSha256: Sha256Hex; querySha256: Sha256Hex; rulePackSha256: Sha256Hex; @@ -157,6 +163,7 @@ export type OhProjectionProofV1 = | Readonly<{ kind: "derived"; premises: readonly OhProjectionProofV1[]; + premisesTruncated: boolean; relation: string; ruleId: string; ruleSha256: Sha256Hex; @@ -173,6 +180,8 @@ export type OhProjectionProofV1 = export type OhProjectionResultRowV1 = Readonly<{ proofs: readonly OhProjectionProofV1[]; + proofsTruncated: boolean; + supportCount: number; values: readonly OhProjectionAtomV1[]; v: 1; }>; @@ -185,7 +194,10 @@ export type OhProjectionResultV1 = Readonly<{ maximumDerivedTuples: number; maximumProofDepth: number; maximumProofNodes: number; + maximumResultBytes: number; maximumRounds: number; + maximumTotalProofNodes: number; + maximumWorkUnits: number; v: 1; }>; identity: OhProjectionIdentityV1; @@ -194,10 +206,14 @@ export type OhProjectionResultV1 = Readonly<{ stats: Readonly<{ baseFacts: number; derivedFacts: number; + proofNodes: number; queryMatches: number; relations: number; rounds: number; + proofsTruncated: boolean; truncated: boolean; + truncationReasons: readonly ("query-limit" | "result-bytes")[]; + workUnits: number; v: 1; }>; v: 1; @@ -207,11 +223,16 @@ export type OhProjectionEvaluationOptionsV1 = Readonly<{ maximumDerivedTuples?: number; maximumProofDepth?: number; maximumProofNodes?: number; + maximumResultBytes?: number; maximumRounds?: number; + maximumTotalProofNodes?: number; + maximumWorkUnits?: number; }>; export type OhProjectionInvalidationReasonV1 = | "dataset-changed" + | "engine-changed" + | "evaluation-changed" | "query-changed" | "rule-pack-changed" | "snapshot-changed"; @@ -646,6 +667,8 @@ export function parseOhProjectionQueryV1(value: unknown): OhProjectionQueryV1 | export function createOhProjectionIdentityV1(input: Readonly<{ dataset: OhProjectionDatasetV1; + engine?: string; + options?: OhProjectionEvaluationOptionsV1; query: OhProjectionQueryV1; rulePack: OhProjectionRulePackV1; snapshot: OhProjectionSnapshotV1; @@ -657,9 +680,14 @@ export function createOhProjectionIdentityV1(input: Readonly<{ if (snapshot === null || dataset === null || query === null || rulePack === null) { throw new TypeError("Invalid projection identity input."); } + const engine = safeCode(input.engine ?? OH_PROJECTION_INTERNAL_ENGINE_V1, 256); + if (engine === null) throw new TypeError("Invalid projection engine identity."); + const evaluation = { ...resolveEvaluationOptions(input.options ?? {}), v: 1 as const }; const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256, datasetSha256: dataset.datasetSha256, + engineSha256: canonicalSha256({ engine, v: 1 }), + evaluationSha256: canonicalSha256(evaluation), querySha256: query.querySha256, rulePackSha256: rulePack.rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, @@ -671,18 +699,23 @@ export function createOhProjectionIdentityV1(input: Readonly<{ export function parseOhProjectionIdentityV1(value: unknown): OhProjectionIdentityV1 | null { if (!isPlainRecord(value) || !hasExactKeys(value, ["contractSha256", "datasetSha256", - "projectionSha256", "querySha256", "rulePackSha256", "semantics", "snapshotSha256", "v"]) + "engineSha256", "evaluationSha256", "projectionSha256", "querySha256", "rulePackSha256", + "semantics", "snapshotSha256", "v"]) || value.v !== 1 || value.semantics !== OH_PROJECTION_SEMANTICS_V1) return null; const contractSha256 = parseSha256Hex(value.contractSha256); const datasetSha256 = parseSha256Hex(value.datasetSha256); + const engineSha256 = parseSha256Hex(value.engineSha256); + const evaluationSha256 = parseSha256Hex(value.evaluationSha256); const projectionSha256 = parseSha256Hex(value.projectionSha256); const querySha256 = parseSha256Hex(value.querySha256); const rulePackSha256 = parseSha256Hex(value.rulePackSha256); const snapshotSha256 = parseSha256Hex(value.snapshotSha256); if (contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256 || datasetSha256 === null - || projectionSha256 === null || querySha256 === null || rulePackSha256 === null + || engineSha256 === null || evaluationSha256 === null || projectionSha256 === null + || querySha256 === null || rulePackSha256 === null || snapshotSha256 === null) return null; - const payload = { contractSha256, datasetSha256, querySha256, rulePackSha256, + const payload = { contractSha256, datasetSha256, engineSha256, evaluationSha256, + querySha256, rulePackSha256, semantics: OH_PROJECTION_SEMANTICS_V1, snapshotSha256, v: 1 as const }; return canonicalSha256(payload) === projectionSha256 ? { ...payload, projectionSha256 } : null; } @@ -696,6 +729,8 @@ export function invalidationForOhProjectionV1(previous: OhProjectionIdentityV1, const reasons: OhProjectionInvalidationReasonV1[] = []; if (parsedPrevious.snapshotSha256 !== parsedNext.snapshotSha256) reasons.push("snapshot-changed"); if (parsedPrevious.datasetSha256 !== parsedNext.datasetSha256) reasons.push("dataset-changed"); + if (parsedPrevious.engineSha256 !== parsedNext.engineSha256) reasons.push("engine-changed"); + if (parsedPrevious.evaluationSha256 !== parsedNext.evaluationSha256) reasons.push("evaluation-changed"); if (parsedPrevious.rulePackSha256 !== parsedNext.rulePackSha256) reasons.push("rule-pack-changed"); if (parsedPrevious.querySha256 !== parsedNext.querySha256) reasons.push("query-changed"); return { kind: "full-rebuild", reasons, v: 1 }; @@ -777,14 +812,22 @@ function unifyLiteral(literal: OhProjectionLiteralV1, state: TupleState, binding type BodyMatch = Readonly<{ binding: Binding; premises: readonly TupleReference[] }>; +type ProjectionWorkBudget = { maximum: number; units: number }; + +function consumeWorkUnit(budget: ProjectionWorkBudget): void { + if (budget.units >= budget.maximum) throw new RangeError("Projection exceeds its work-unit bound."); + budget.units += 1; +} + function matchBody(relations: Map, body: readonly OhProjectionLiteralV1[], - maximumMatches: number): readonly BodyMatch[] { + maximumMatches: number, work: ProjectionWorkBudget): readonly BodyMatch[] { let matches: readonly BodyMatch[] = [{ binding: new Map(), premises: [] }]; for (const literal of body) { const next: BodyMatch[] = []; const candidates = relationTuples(relations, literal.relation); for (const match of matches) { for (const candidate of candidates) { + consumeWorkUnit(work); const binding = unifyLiteral(literal, candidate, match.binding); if (binding === null) continue; next.push({ binding, premises: [...match.premises, { relation: literal.relation, @@ -813,6 +856,7 @@ function materializeNaive(input: Readonly<{ maximumDerivedTuples: number; maximumRounds: number; rulePack: OhProjectionRulePackV1; + work: ProjectionWorkBudget; }>): MaterializedProjection { const relations = new Map(); for (const fact of input.dataset.facts) { @@ -828,7 +872,7 @@ function materializeNaive(input: Readonly<{ while (true) { const candidates = new Map>(); for (const rule of input.rulePack.rules) { - for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches)) { + for (const match of matchBody(relations, rule.body, OH_PROJECTION_LIMITS_V1.queryMatches, input.work)) { const derivedTuple = instantiateHead(rule.head, match.binding); const relation = relations.get(rule.head.relation); const key = tupleKey(derivedTuple); @@ -873,53 +917,91 @@ type ResolvedEvaluationOptions = Readonly<{ maximumDerivedTuples: number; maximumProofDepth: number; maximumProofNodes: number; + maximumResultBytes: number; maximumRounds: number; + maximumTotalProofNodes: number; + maximumWorkUnits: number; }>; function resolveEvaluationOptions(options: OhProjectionEvaluationOptionsV1): ResolvedEvaluationOptions { - return { + const resolved = { maximumDerivedTuples: boundedOption(options.maximumDerivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, OH_PROJECTION_LIMITS_V1.derivedTuples, "maximumDerivedTuples"), maximumProofDepth: boundedOption(options.maximumProofDepth, 32, OH_PROJECTION_LIMITS_V1.proofDepth, "maximumProofDepth"), maximumProofNodes: boundedOption(options.maximumProofNodes, 1_024, OH_PROJECTION_LIMITS_V1.proofNodes, "maximumProofNodes"), + maximumResultBytes: boundedOption(options.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes, + OH_PROJECTION_LIMITS_V1.resultBytes, "maximumResultBytes"), maximumRounds: boundedOption(options.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds, OH_PROJECTION_LIMITS_V1.rounds, "maximumRounds"), + maximumTotalProofNodes: boundedOption(options.maximumTotalProofNodes, + OH_PROJECTION_LIMITS_V1.totalProofNodes, OH_PROJECTION_LIMITS_V1.totalProofNodes, + "maximumTotalProofNodes"), + maximumWorkUnits: boundedOption(options.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits, + OH_PROJECTION_LIMITS_V1.workUnits, "maximumWorkUnits"), }; + if (resolved.maximumResultBytes < 64 * 1024) { + throw new RangeError("maximumResultBytes must be at least 65536."); + } + return resolved; +} + +type ProjectionResultBudget = { bytes: number; maximumBytes: number; nodes: number }; +type ProjectionRowProofBudget = { nodes: number; result: ProjectionResultBudget }; + +function reserveResultBytes(budget: ProjectionResultBudget, value: unknown): boolean { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) return false; + budget.bytes += bytes; + return true; +} + +function reserveProofNode(budget: ProjectionRowProofBudget, options: ResolvedEvaluationOptions, + envelope: OhProjectionProofV1): boolean { + if (budget.nodes >= options.maximumProofNodes + || budget.result.nodes >= options.maximumTotalProofNodes + || !reserveResultBytes(budget.result, envelope)) return false; + budget.nodes += 1; + budget.result.nodes += 1; + return true; } function proofForReference(relations: Map, reference: TupleReference, - budget: { nodes: number }, options: ResolvedEvaluationOptions, depth: number, + budget: ProjectionRowProofBudget, options: ResolvedEvaluationOptions, depth: number, visiting: Set): OhProjectionProofV1 | null { - if (budget.nodes >= options.maximumProofNodes) return null; - if (budget.nodes === options.maximumProofNodes - 1) { - budget.nodes += 1; - return { kind: "truncated", reason: "nodes", relation: reference.relation, tuple: reference.tuple, v: 1 }; - } - budget.nodes += 1; if (depth >= options.maximumProofDepth) { - return { kind: "truncated", reason: "depth", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { kind: "truncated" as const, reason: "depth" as const, + relation: reference.relation, tuple: reference.tuple, v: 1 as const }; + return reserveProofNode(budget, options, proof) ? proof : null; } const identity = referenceKey(reference); if (visiting.has(identity)) { - return { kind: "truncated", reason: "cycle", relation: reference.relation, tuple: reference.tuple, v: 1 }; + const proof = { kind: "truncated" as const, reason: "cycle" as const, + relation: reference.relation, tuple: reference.tuple, v: 1 as const }; + return reserveProofNode(budget, options, proof) ? proof : null; } const state = relations.get(reference.relation)?.get(tupleKey(reference.tuple)); if (state === undefined) throw new Error("Projection proof references a tuple outside the materialized result."); if (state.witness.kind === "fact") { - return { kind: "fact", relation: reference.relation, sources: state.witness.sources, - tuple: reference.tuple, v: 1 }; + const proof = { kind: "fact" as const, relation: reference.relation, sources: state.witness.sources, + tuple: reference.tuple, v: 1 as const }; + return reserveProofNode(budget, options, proof) ? proof : null; } + const envelope = { kind: "derived" as const, premises: [], premisesTruncated: false, + relation: reference.relation, ruleId: state.witness.rule.ruleId, + ruleSha256: state.witness.rule.ruleSha256, tuple: reference.tuple, v: 1 as const }; + if (!reserveProofNode(budget, options, envelope)) return null; visiting.add(identity); try { const premises: OhProjectionProofV1[] = []; + let premisesTruncated = false; for (const premise of state.witness.premises) { const proof = proofForReference(relations, premise, budget, options, depth + 1, visiting); - if (proof === null) break; + if (proof === null) { premisesTruncated = true; break; } premises.push(proof); } - return { kind: "derived", premises, + return { kind: "derived", premises, premisesTruncated, relation: reference.relation, ruleId: state.witness.rule.ruleId, ruleSha256: state.witness.rule.ruleSha256, tuple: reference.tuple, v: 1 }; } finally { @@ -927,6 +1009,252 @@ function proofForReference(relations: Map, reference: Tup } } +function proofIsTruncated(proof: OhProjectionProofV1): boolean { + return proof.kind === "truncated" || (proof.kind === "derived" + && (proof.premisesTruncated || proof.premises.some(proofIsTruncated))); +} + +type ProjectionParseBudget = { + bytes: number; + maximumBytes: number; + maximumDepth: number; + maximumNodes: number; + nodes: number; +}; + +function reserveProjectionParseBytes(budget: ProjectionParseBudget, value: unknown): boolean { + const bytes = utf8ByteLength(canonicalJson(value)); + if (budget.bytes + bytes > budget.maximumBytes) return false; + budget.bytes += bytes; + return true; +} + +function parseProjectionFactSource(value: unknown): OhProjectionFactSourceV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["key", "recordSha256", "v"]) || value.v !== 1) { + return null; + } + const key = safeCode(value.key, 512); + const recordSha256 = parseSha256Hex(value.recordSha256); + return key === null || recordSha256 === null ? null : { key, recordSha256, v: 1 }; +} + +function parseProjectionProofWithBudget(value: unknown, budget: ProjectionParseBudget, + depth: number): OhProjectionProofV1 | null { + if (depth > budget.maximumDepth || budget.nodes >= budget.maximumNodes + || !isPlainRecord(value) || value.v !== 1) return null; + const relation = projectionName(value.relation); + const parsedTuple = tuple(value.tuple); + if (relation === null || parsedTuple === null) return null; + + if (value.kind === "fact") { + if (!hasExactKeys(value, ["kind", "relation", "sources", "tuple", "v"]) + || !Array.isArray(value.sources) || value.sources.length < 1 + || value.sources.length > OH_PROJECTION_LIMITS_V1.sourcesPerFact) return null; + const sources = value.sources.map(parseProjectionFactSource); + if (sources.some((source) => source === null)) return null; + const parsedSources = sources as readonly OhProjectionFactSourceV1[]; + if (!orderedUnique(parsedSources, (source) => source.key)) return null; + const proof = { kind: "fact" as const, relation, sources: parsedSources, + tuple: parsedTuple, v: 1 as const }; + if (!reserveProjectionParseBytes(budget, proof)) return null; + budget.nodes += 1; + return proof; + } + + if (value.kind === "truncated") { + if (!hasExactKeys(value, ["kind", "reason", "relation", "tuple", "v"]) + || (value.reason !== "cycle" && value.reason !== "depth" && value.reason !== "nodes")) return null; + const reason = value.reason as "cycle" | "depth" | "nodes"; + const proof = { kind: "truncated" as const, reason, + relation, tuple: parsedTuple, v: 1 as const }; + if (!reserveProjectionParseBytes(budget, proof)) return null; + budget.nodes += 1; + return proof; + } + + if (value.kind !== "derived" || !hasExactKeys(value, ["kind", "premises", "premisesTruncated", + "relation", "ruleId", "ruleSha256", "tuple", "v"]) || !Array.isArray(value.premises) + || value.premises.length > OH_PROJECTION_LIMITS_V1.literalsPerRule + || typeof value.premisesTruncated !== "boolean") return null; + const ruleId = projectionName(value.ruleId); + const ruleSha256 = parseSha256Hex(value.ruleSha256); + if (ruleId === null || ruleSha256 === null + || (!value.premisesTruncated && value.premises.length === 0) + || (value.premisesTruncated && value.premises.length === OH_PROJECTION_LIMITS_V1.literalsPerRule)) { + return null; + } + const skeleton = { kind: "derived" as const, premises: [], + premisesTruncated: value.premisesTruncated, relation, ruleId, ruleSha256, + tuple: parsedTuple, v: 1 as const }; + if (!reserveProjectionParseBytes(budget, skeleton)) return null; + budget.nodes += 1; + const premises: OhProjectionProofV1[] = []; + for (const premise of value.premises) { + const parsed = parseProjectionProofWithBudget(premise, budget, depth + 1); + if (parsed === null) return null; + premises.push(parsed); + } + return { ...skeleton, premises }; +} + +/** + * Parses one untrusted proof tree under the public hard depth, node, and byte + * ceilings. Cached result envelopes should normally be parsed as a whole with + * `parseOhProjectionResultV1`, which also applies their smaller declared limits. + */ +export function parseOhProjectionProofV1(value: unknown): OhProjectionProofV1 | null { + try { + const budget: ProjectionParseBudget = { bytes: 0, + maximumBytes: OH_PROJECTION_LIMITS_V1.resultBytes, + maximumDepth: OH_PROJECTION_LIMITS_V1.proofDepth, + maximumNodes: OH_PROJECTION_LIMITS_V1.proofNodes, nodes: 0 }; + const proof = parseProjectionProofWithBudget(value, budget, 0); + return proof !== null && utf8ByteLength(canonicalJson(proof)) <= budget.maximumBytes ? proof : null; + } catch { + return null; + } +} + +type OhProjectionResolvedEvaluationV1 = ResolvedEvaluationOptions & Readonly<{ v: 1 }>; + +function parseProjectionEvaluation(value: unknown): OhProjectionResolvedEvaluationV1 | null { + if (!isPlainRecord(value) || !hasExactKeys(value, ["maximumDerivedTuples", "maximumProofDepth", + "maximumProofNodes", "maximumResultBytes", "maximumRounds", "maximumTotalProofNodes", + "maximumWorkUnits", "v"]) || value.v !== 1) return null; + const maximumDerivedTuples = positiveInteger(value.maximumDerivedTuples, + OH_PROJECTION_LIMITS_V1.derivedTuples); + const maximumProofDepth = positiveInteger(value.maximumProofDepth, OH_PROJECTION_LIMITS_V1.proofDepth); + const maximumProofNodes = positiveInteger(value.maximumProofNodes, OH_PROJECTION_LIMITS_V1.proofNodes); + const maximumResultBytes = positiveInteger(value.maximumResultBytes, OH_PROJECTION_LIMITS_V1.resultBytes); + const maximumRounds = positiveInteger(value.maximumRounds, OH_PROJECTION_LIMITS_V1.rounds); + const maximumTotalProofNodes = positiveInteger(value.maximumTotalProofNodes, + OH_PROJECTION_LIMITS_V1.totalProofNodes); + const maximumWorkUnits = positiveInteger(value.maximumWorkUnits, OH_PROJECTION_LIMITS_V1.workUnits); + if (maximumDerivedTuples === null || maximumProofDepth === null || maximumProofNodes === null + || maximumResultBytes === null || maximumResultBytes < 64 * 1024 || maximumRounds === null + || maximumTotalProofNodes === null || maximumWorkUnits === null) return null; + return { maximumDerivedTuples, maximumProofDepth, maximumProofNodes, maximumResultBytes, + maximumRounds, maximumTotalProofNodes, maximumWorkUnits, v: 1 }; +} + +function parseProjectionResultRow(value: unknown, evaluation: OhProjectionResolvedEvaluationV1, + resultBudget: ProjectionParseBudget): Readonly<{ nodes: number; row: OhProjectionResultRowV1 }> | null { + if (!isPlainRecord(value) || !hasExactKeys(value, + ["proofs", "proofsTruncated", "supportCount", "values", "v"]) || value.v !== 1 + || !Array.isArray(value.proofs) || value.proofs.length > OH_PROJECTION_LIMITS_V1.queryLiterals + || typeof value.proofsTruncated !== "boolean") return null; + const values = tuple(value.values); + const supportCount = positiveInteger(value.supportCount, OH_PROJECTION_LIMITS_V1.queryMatches); + if (values === null || supportCount === null || (!value.proofsTruncated && value.proofs.length === 0)) return null; + if (!reserveProjectionParseBytes(resultBudget, { proofs: [], proofsTruncated: value.proofsTruncated, + supportCount, values, v: 1 })) return null; + const before = resultBudget.nodes; + resultBudget.maximumNodes = Math.min(resultBudget.maximumNodes, before + evaluation.maximumProofNodes); + const proofs: OhProjectionProofV1[] = []; + for (const proof of value.proofs) { + const parsed = parseProjectionProofWithBudget(proof, resultBudget, 0); + if (parsed === null) return null; + proofs.push(parsed); + } + resultBudget.maximumNodes = evaluation.maximumTotalProofNodes; + const containsTruncation = proofs.some(proofIsTruncated); + if ((!value.proofsTruncated && containsTruncation) + || (value.proofsTruncated && proofs.length === OH_PROJECTION_LIMITS_V1.queryLiterals + && !containsTruncation)) return null; + return { nodes: resultBudget.nodes - before, + row: { proofs, proofsTruncated: value.proofsTruncated, supportCount, values, v: 1 } }; +} + +/** + * Parses a cached projection result as untrusted data. It verifies exact keys, + * all aggregate and declared bounds, proof truncation markers, canonical row + * order, engine/evaluation identity links, and `resultSha256`. Pass the + * projection digest requested from a cache to reject identity substitution. + */ +export function parseOhProjectionResultV1(value: unknown, + expectedProjectionSha256?: Sha256Hex): OhProjectionResultV1 | null { + try { + if (!isPlainRecord(value) || !hasExactKeys(value, ["authority", "cache", "engine", "evaluation", + "identity", "resultSha256", "rows", "stats", "v"]) || value.v !== 1 + || value.authority !== "derived" || !isPlainRecord(value.cache) + || !hasExactKeys(value.cache, ["strategy", "v"]) || value.cache.strategy !== "full-rebuild" + || value.cache.v !== 1 || !Array.isArray(value.rows) + || value.rows.length > OH_PROJECTION_LIMITS_V1.queryResults || !isPlainRecord(value.stats) + || !hasExactKeys(value.stats, ["baseFacts", "derivedFacts", "proofNodes", "proofsTruncated", + "queryMatches", "relations", "rounds", "truncated", "truncationReasons", "v", "workUnits"]) + || value.stats.v !== 1 || !Array.isArray(value.stats.truncationReasons) + || typeof value.stats.proofsTruncated !== "boolean" || typeof value.stats.truncated !== "boolean") return null; + const engine = safeCode(value.engine, 256); + const evaluation = parseProjectionEvaluation(value.evaluation); + const identity = parseOhProjectionIdentityV1(value.identity); + const resultSha256 = parseSha256Hex(value.resultSha256); + const expected = expectedProjectionSha256 === undefined ? undefined + : parseSha256Hex(expectedProjectionSha256); + if (engine === null || evaluation === null || identity === null || resultSha256 === null + || (expectedProjectionSha256 !== undefined && expected === null) + || (expected !== undefined && identity.projectionSha256 !== expected) + || identity.engineSha256 !== canonicalSha256({ engine, v: 1 }) + || identity.evaluationSha256 !== canonicalSha256(evaluation)) return null; + + const baseFacts = nonnegativeInteger(value.stats.baseFacts); + const derivedFacts = nonnegativeInteger(value.stats.derivedFacts); + const proofNodes = nonnegativeInteger(value.stats.proofNodes); + const queryMatches = nonnegativeInteger(value.stats.queryMatches); + const relations = nonnegativeInteger(value.stats.relations); + const rounds = nonnegativeInteger(value.stats.rounds); + const workUnits = nonnegativeInteger(value.stats.workUnits); + if (baseFacts === null || baseFacts > OH_PROJECTION_LIMITS_V1.facts + || derivedFacts === null || derivedFacts > evaluation.maximumDerivedTuples + || proofNodes === null || proofNodes > evaluation.maximumTotalProofNodes + || queryMatches === null || queryMatches > OH_PROJECTION_LIMITS_V1.queryMatches + || relations === null || relations > OH_PROJECTION_LIMITS_V1.relations + || rounds === null || rounds > evaluation.maximumRounds + || workUnits === null || workUnits > evaluation.maximumWorkUnits + || relations > baseFacts + derivedFacts || rounds > derivedFacts + || ((rounds === 0) !== (derivedFacts === 0)) || queryMatches > workUnits) return null; + const truncationReasons = value.stats.truncationReasons; + if (truncationReasons.length > 2 + || !orderedUnique(truncationReasons, (reason) => reason === "query-limit" ? "0" : reason === "result-bytes" ? "1" : "x") + || truncationReasons.some((reason) => reason !== "query-limit" && reason !== "result-bytes") + || value.stats.truncated !== (truncationReasons.length > 0)) return null; + + const budget: ProjectionParseBudget = { bytes: 0, maximumBytes: evaluation.maximumResultBytes, + maximumDepth: evaluation.maximumProofDepth, maximumNodes: evaluation.maximumTotalProofNodes, nodes: 0 }; + const rows: OhProjectionResultRowV1[] = []; + let supportCount = 0; + for (const row of value.rows) { + const parsed = parseProjectionResultRow(row, evaluation, budget); + if (parsed === null) return null; + rows.push(parsed.row); + supportCount += parsed.row.supportCount; + if (supportCount > queryMatches) return null; + } + if (!orderedUnique(rows, (row) => canonicalJson(row.values)) || budget.nodes !== proofNodes + || value.stats.proofsTruncated !== rows.some((row) => row.proofsTruncated) + || (value.stats.truncated ? supportCount >= queryMatches : supportCount !== queryMatches)) return null; + + const reasons = truncationReasons as readonly ("query-limit" | "result-bytes")[]; + const payload = { + authority: "derived" as const, + cache: { strategy: "full-rebuild" as const, v: 1 as const }, + engine, + evaluation, + identity, + rows, + stats: { baseFacts, derivedFacts, proofNodes, proofsTruncated: value.stats.proofsTruncated, + queryMatches, relations, rounds, truncated: value.stats.truncated, + truncationReasons: reasons, v: 1 as const, workUnits }, + v: 1 as const, + }; + const serialized = canonicalJson(payload); + return utf8ByteLength(serialized) <= evaluation.maximumResultBytes + && sha256Hex(serialized) === resultSha256 + ? { ...payload, resultSha256 } : null; + } catch { + return null; + } +} + function buildProjectionResult(input: Readonly<{ dataset: OhProjectionDatasetV1; engine: string; @@ -935,31 +1263,49 @@ function buildProjectionResult(input: Readonly<{ query: OhProjectionQueryV1; rulePack: OhProjectionRulePackV1; snapshot: OhProjectionSnapshotV1; + work: ProjectionWorkBudget; }>): OhProjectionResultV1 { const matches = matchBody(input.materialized.relations, input.query.where, - OH_PROJECTION_LIMITS_V1.queryMatches); - const byValues = new Map(); + OH_PROJECTION_LIMITS_V1.queryMatches, input.work); + const byValues = new Map(); for (const match of matches) { const values = input.query.find.map((name) => match.binding.get(name) as OhProjectionAtomV1); const key = tupleKey(values); const existing = byValues.get(key); - if (existing === undefined || compareCanonical(match.premises, existing.premises) < 0) byValues.set(key, match); + if (existing === undefined) byValues.set(key, { match, supportCount: 1 }); + else byValues.set(key, { match: compareCanonical(match.premises, existing.match.premises) < 0 + ? match : existing.match, supportCount: existing.supportCount + 1 }); } const ordered = [...byValues.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); - const truncated = ordered.length > input.query.limit; - const rows = ordered.slice(0, input.query.limit).map(([key, match]) => { + const resultBudget: ProjectionResultBudget = { bytes: 0, + maximumBytes: input.options.maximumResultBytes - 64 * 1024, nodes: 0 }; + const rows: OhProjectionResultRowV1[] = []; + let resultBytesTruncated = false; + for (const [key, support] of ordered.slice(0, input.query.limit)) { const values = JSON.parse(key) as OhProjectionAtomV1[]; - const budget = { nodes: 0 }; + if (!reserveResultBytes(resultBudget, { proofs: [], proofsTruncated: false, + supportCount: support.supportCount, values, v: 1 })) { + resultBytesTruncated = true; + break; + } + const budget: ProjectionRowProofBudget = { nodes: 0, result: resultBudget }; const proofs: OhProjectionProofV1[] = []; - for (const premise of match.premises) { + for (const premise of support.match.premises) { const proof = proofForReference(input.materialized.relations, premise, budget, input.options, 0, new Set()); if (proof === null) break; proofs.push(proof); } - return { proofs, values, v: 1 as const }; - }); + const proofsTruncated = proofs.length !== support.match.premises.length || proofs.some(proofIsTruncated); + rows.push({ proofs, proofsTruncated, supportCount: support.supportCount, values, v: 1 }); + } + const queryLimitTruncated = ordered.length > input.query.limit; + const truncationReasons = [ + ...(queryLimitTruncated ? ["query-limit" as const] : []), + ...(resultBytesTruncated ? ["result-bytes" as const] : []), + ]; + const truncated = truncationReasons.length > 0; const identity = createOhProjectionIdentityV1({ dataset: input.dataset, query: input.query, - rulePack: input.rulePack, snapshot: input.snapshot }); + engine: input.engine, options: input.options, rulePack: input.rulePack, snapshot: input.snapshot }); const payload = { authority: "derived" as const, cache: { strategy: "full-rebuild" as const, v: 1 as const }, @@ -968,11 +1314,17 @@ function buildProjectionResult(input: Readonly<{ identity, rows, stats: { baseFacts: input.materialized.baseFacts, derivedFacts: input.materialized.derivedFacts, - queryMatches: matches.length, relations: input.materialized.relations.size, - rounds: input.materialized.rounds, truncated, v: 1 as const }, + proofNodes: resultBudget.nodes, proofsTruncated: rows.some((row) => row.proofsTruncated), + queryMatches: matches.length, + relations: input.materialized.relations.size, rounds: input.materialized.rounds, + truncated, truncationReasons, v: 1 as const, workUnits: input.work.units }, v: 1 as const, }; - return { ...payload, resultSha256: canonicalSha256(payload) }; + const serialized = canonicalJson(payload); + if (utf8ByteLength(serialized) > input.options.maximumResultBytes) { + throw new RangeError("Projection result exceeds its canonical byte bound."); + } + return { ...payload, resultSha256: sha256Hex(serialized) }; } export function evaluateOhProjectionV1(input: Readonly<{ @@ -991,10 +1343,11 @@ export function evaluateOhProjectionV1(input: Readonly<{ } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; const materialized = materializeNaive({ dataset, - maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack }); + maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack, work }); return buildProjectionResult({ dataset, engine: OH_PROJECTION_INTERNAL_ENGINE_V1, - materialized, options, query, rulePack, snapshot }); + materialized, options, query, rulePack, snapshot, work }); } /** @@ -1026,10 +1379,11 @@ export function evaluateOhProjectionWithMaterializerV1(input: Readonly<{ } const options = resolveEvaluationOptions(input.options ?? {}); validateProgramArities(dataset, rulePack, query); + const work = { maximum: options.maximumWorkUnits, units: 0 }; + const witnessMaterialization = materializeNaive({ dataset, + maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack, work }); const external = input.materialize({ dataset, maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, query, rulePack }); - const witnessMaterialization = materializeNaive({ dataset, - maximumDerivedTuples: options.maximumDerivedTuples, maximumRounds: options.maximumRounds, rulePack }); const externalCanonical = new Map(); for (const [relationName, tuples] of external.relationFacts) { const relation = projectionName(relationName); @@ -1054,7 +1408,7 @@ export function evaluateOhProjectionWithMaterializerV1(input: Readonly<{ } } return buildProjectionResult({ dataset, engine, - materialized: witnessMaterialization, options, query, rulePack, snapshot }); + materialized: witnessMaterialization, options, query, rulePack, snapshot, work }); } export type OhProjectionRecordFactOptionsV1 = Readonly<{ @@ -1066,10 +1420,23 @@ export type OhProjectionRecordFactOptionsV1 = Readonly<{ export function createOhProjectionRecordFactsV1(records: readonly KnowledgeGraphRecordV1[], options: OhProjectionRecordFactOptionsV1 = {}): readonly OhProjectionFactV1[] { if (records.length > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) throw new RangeError("Too many records for projection facts."); + const parsedRecords = [...records] + .sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0) + .map((candidate) => { + const record = parseKnowledgeGraphRecordV1(candidate); + if (record === null) throw new TypeError("Invalid graph record for projection facts."); + return record; + }); + let projectedFactCount = 0; + for (const record of parsedRecords) { + if (options.includeRecords !== false) projectedFactCount += 1; + if (options.includeDependencies !== false) projectedFactCount += record.dependencies.length; + if (projectedFactCount > OH_PROJECTION_LIMITS_V1.facts) { + throw new RangeError("Structural projection exceeds its fact bound."); + } + } const facts: OhProjectionFactV1[] = []; - for (const candidate of [...records].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) { - const record = parseKnowledgeGraphRecordV1(candidate); - if (record === null) throw new TypeError("Invalid graph record for projection facts."); + for (const record of parsedRecords) { const source = [{ key: record.key, recordSha256: record.recordSha256, v: 1 as const }]; if (options.includeRecords !== false) { facts.push(createOhProjectionFactV1({ relation: "oh.record", sources: source, diff --git a/src/sqlite/store.test.ts b/src/sqlite/store.test.ts index e874c61..431dcb2 100644 --- a/src/sqlite/store.test.ts +++ b/src/sqlite/store.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { createKnowledgeGraphRecordV1 } from "../graph"; +import { createOhStoreBindingV1, OH_WORKING_STORE_PROFILE_V1 } from "../store"; import { OhConflictError, OhDependencyError, OhIntegrityError, OhSqliteStore } from "./store"; const roots: string[] = []; @@ -63,6 +64,67 @@ describe("Oh SQLite authority", () => { store.close(); }); + test("rejects orphaned and cross-space rows as idempotent operations", () => { + const changes = [{ kind: "put" as const, record: record("entity:orphan", "Orphan"), v: 1 as const }]; + const source = new OhSqliteStore({ path: ":memory:", spaceId: "orphan-target" }); + const operation = source.commit({ actorId: "agent.test", changes, expectedHead: source.head(), + operationId: "op_orphan" }); + const target = new OhSqliteStore({ path: ":memory:", spaceId: "orphan-target" }); + target.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, + operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, + operation_json, instant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + operation.operationSha256, operation.spaceId, operation.sequence, operation.operationId, + operation.parentOperationSha256, operation.graphRevisionSha256, operation.recordsSha256, + JSON.stringify(operation), operation.instant, + ); + expect(() => target.commit({ actorId: operation.actorId, changes, expectedHead: target.head(), + operationId: operation.operationId })).toThrow(OhIntegrityError); + expect(() => target.importOperation(operation)).toThrow(OhIntegrityError); + expect(target.head().sequence).toBe(0); + source.close(); target.close(); + + const alienSource = new OhSqliteStore({ path: ":memory:", spaceId: "alien-source" }); + const alien = alienSource.commit({ actorId: "agent.test", changes, expectedHead: alienSource.head(), + operationId: "op_alien" }); + const alienTarget = new OhSqliteStore({ path: ":memory:", spaceId: "alien-target" }); + alienTarget.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, + operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, + operation_json, instant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + alien.operationSha256, alienTarget.spaceId, alien.sequence, alien.operationId, + alien.parentOperationSha256, alien.graphRevisionSha256, alien.recordsSha256, + JSON.stringify(alien), alien.instant, + ); + alienTarget.database.query(`UPDATE oh_spaces SET generation = 1, head_operation_sha256 = ?, + graph_revision_sha256 = ?, records_sha256 = ?, sequence = 1 WHERE space_id = ?`).run( + alien.operationSha256, alien.graphRevisionSha256, alien.recordsSha256, alienTarget.spaceId, + ); + expect(() => alienTarget.commit({ actorId: alien.actorId, changes, + expectedHead: alienTarget.head(), operationId: alien.operationId })).toThrow(OhIntegrityError); + alienSource.close(); alienTarget.close(); + }); + + test("refuses to advance a space whose duplicated binding or current head drifted", () => { + const store = new OhSqliteStore({ path: ":memory:", spaceId: "authority-drift" }); + const binding = createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:authority-drift", spaceId: store.spaceId, v: 1 }); + store.bind(binding); + store.database.query("UPDATE oh_space_bindings SET realm_id = ? WHERE space_id = ?") + .run("realm:alien", store.spaceId); + expect(() => store.binding()).toThrow(OhIntegrityError); + store.database.query("UPDATE oh_space_bindings SET realm_id = ? WHERE space_id = ?") + .run(binding.realmId, store.spaceId); + store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record("entity:one", "One"), v: 1 }], expectedHead: store.head(), operationId: "op_one" }); + store.database.query("UPDATE oh_spaces SET graph_revision_sha256 = ? WHERE space_id = ?") + .run("f".repeat(64), store.spaceId); + const drifted = store.head(); + expect(() => store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record("entity:two", "Two"), v: 1 }], expectedHead: drifted, + operationId: "op_two" })).toThrow(OhIntegrityError); + expect(store.head().sequence).toBe(1); + store.close(); + }); + test("checks final dependency closure before changing durable state", () => { const store = new OhSqliteStore({ path: ":memory:" }); expect(() => store.commit({ actorId: "agent.test", changes: [{ kind: "put", @@ -119,6 +181,16 @@ describe("Oh SQLite authority", () => { store.close(); }); + test("verifies duplicated operation columns against canonical envelopes", () => { + const store = new OhSqliteStore({ path: ":memory:" }); + store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record("entity:a", "A"), v: 1 }], expectedHead: store.head(), operationId: "op_columns" }); + store.database.query("UPDATE oh_operations SET operation_id = ? WHERE space_id = ?") + .run("op_alien", store.spaceId); + expect(() => store.verifyReplay()).toThrow(OhIntegrityError); + store.close(); + }); + test("checks canonical operation bytes beyond the export batch ceiling", () => { const store = new OhSqliteStore({ path: ":memory:" }); for (let index = 0; index < 1001; index += 1) { @@ -128,7 +200,69 @@ describe("Oh SQLite authority", () => { } store.database.query(`UPDATE oh_operations SET operation_json = ' ' || operation_json WHERE space_id = ? AND sequence = 1`).run(store.spaceId); - expect(() => store.verifyReplay()).toThrow("not canonical JSON"); + expect(() => store.verifyReplay()).toThrow(OhIntegrityError); + store.close(); + }); + + test("never reports a feed page that omits its tail or hides a bad sentinel", () => { + const tail = new OhSqliteStore({ path: ":memory:", spaceId: "feed-tail" }); + for (let index = 1; index <= 3; index += 1) { + tail.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record(`entity:tail-${index}`, `Tail ${index}`), v: 1 }], expectedHead: tail.head(), + operationId: `op_tail_${index}` }); + } + tail.database.exec("PRAGMA foreign_keys = OFF"); + tail.database.query("DELETE FROM oh_operations WHERE space_id = ? AND sequence = 3").run(tail.spaceId); + tail.database.exec("PRAGMA foreign_keys = ON"); + expect(() => tail.changesSince({ operationSha256: null, sequence: 0 }, { limit: 3 })) + .toThrow(OhIntegrityError); + tail.close(); + + const sentinel = new OhSqliteStore({ path: ":memory:", spaceId: "feed-sentinel" }); + for (let index = 1; index <= 3; index += 1) { + sentinel.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record(`entity:sentinel-${index}`, `Sentinel ${index}`), v: 1 }], + expectedHead: sentinel.head(), operationId: `op_sentinel_${index}` }); + } + sentinel.database.exec("PRAGMA foreign_keys = OFF"); + sentinel.database.query("DELETE FROM oh_operations WHERE space_id = ? AND sequence = 2") + .run(sentinel.spaceId); + sentinel.database.exec("PRAGMA foreign_keys = ON"); + expect(() => sentinel.changesSince({ operationSha256: null, sequence: 0 }, { limit: 1 })) + .toThrow(OhIntegrityError); + sentinel.close(); + }); + + test("rolls a working-space purge back when a payload delete is masked", () => { + const store = new OhSqliteStore({ path: ":memory:", spaceId: "purge-postcondition" }); + const binding = createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:purge-postcondition", spaceId: store.spaceId, v: 1 }); + store.bind(binding); + store.commit({ actorId: "agent.test", changes: [{ kind: "put", + record: record("entity:private", "Private"), v: 1 }], expectedHead: store.head(), + operationId: "op_private" }); + store.database.exec(`CREATE TRIGGER mask_private_operation_delete BEFORE DELETE ON oh_operations + BEGIN SELECT RAISE(IGNORE); END`); + store.database.exec(`CREATE TRIGGER mask_private_space_delete BEFORE DELETE ON oh_spaces + BEGIN SELECT RAISE(IGNORE); END`); + expect(() => store.purgeWorkingSpace(binding, "2026-08-29T13:00:00.000Z")) + .toThrow(OhIntegrityError); + expect(store.database.query<{ count: number }, []>( + "SELECT count(*) AS count FROM oh_space_purges").get()?.count).toBe(0); + expect(store.get("entity:private")).not.toBeNull(); + expect(store.head().sequence).toBe(1); + store.close(); + }); + + test("cross-checks every stored purge receipt column before refusing resurrection", () => { + const store = new OhSqliteStore({ path: ":memory:", spaceId: "purge-columns" }); + const binding = createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:purge-columns", spaceId: store.spaceId, v: 1 }); + store.bind(binding); + store.purgeWorkingSpace(binding, "2026-08-29T13:00:00.000Z"); + store.database.query("UPDATE oh_space_purges SET prior_sequence = 99 WHERE space_id = ?") + .run(store.spaceId); + expect(() => store.ensureSpace()).toThrow(OhIntegrityError); store.close(); }); diff --git a/src/sqlite/store.ts b/src/sqlite/store.ts index 731cece..50fc572 100644 --- a/src/sqlite/store.ts +++ b/src/sqlite/store.ts @@ -103,9 +103,100 @@ type CurrentRecordRow = { record_sha256: string; sequence: number; }; -type OperationRow = { operation_json: string }; -type BindingRow = { binding_json: string }; -type PurgeRow = { receipt_json: string }; +type OperationRow = { + graph_revision_sha256: string; + instant: string; + operation_id: string; + operation_json: string; + operation_sha256: string; + parent_operation_sha256: string | null; + records_sha256: string; + sequence: number; + space_id: string; +}; +type BindingRow = { + binding_json: string; + binding_sha256: string; + profile_id: string; + profile_kind: string; + profile_sha256: string; + realm_id: string; + space_id: string; +}; +type PurgeRow = { + binding_sha256: string; + prior_operation_sha256: string | null; + prior_sequence: number; + purged_at: string; + receipt_json: string; + receipt_sha256: string; + space_id: string; +}; + +const OPERATION_COLUMNS = `operation_sha256, space_id, sequence, operation_id, + parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`; +const BINDING_COLUMNS = `space_id, realm_id, profile_id, profile_kind, profile_sha256, + binding_sha256, binding_json`; +const PURGE_COLUMNS = `space_id, binding_sha256, prior_operation_sha256, prior_sequence, + purged_at, receipt_sha256, receipt_json`; + +function parseStoredOperationRow( + row: OperationRow, + expected: Readonly<{ operationId?: string; operationSha256?: string; spaceId?: string }> = {}, +): OhOperationV1 { + let value: unknown; + try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } + const operation = parseOhOperationV1(value); + if (operation === null || canonicalJson(operation) !== row.operation_json + || row.operation_sha256 !== operation.operationSha256 + || row.space_id !== operation.spaceId + || row.sequence !== operation.sequence + || row.operation_id !== operation.operationId + || row.parent_operation_sha256 !== operation.parentOperationSha256 + || row.graph_revision_sha256 !== operation.graphRevisionSha256 + || row.records_sha256 !== operation.recordsSha256 + || row.instant !== operation.instant + || (expected.spaceId !== undefined && operation.spaceId !== expected.spaceId) + || (expected.operationId !== undefined && operation.operationId !== expected.operationId) + || (expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256)) { + throw new OhIntegrityError("Stored operation columns do not match their canonical envelope."); + } + return operation; +} + +function parseStoredBindingRow(row: BindingRow, expectedSpaceId: string): OhStoreBindingV1 { + let value: unknown; + try { value = JSON.parse(row.binding_json); } catch { throw new OhIntegrityError("A store binding is not JSON."); } + const binding = parseOhStoreBindingV1(value); + if (binding === null || canonicalJson(binding) !== row.binding_json + || binding.spaceId !== expectedSpaceId + || row.space_id !== binding.spaceId + || row.realm_id !== binding.realmId + || row.profile_id !== binding.profile.profileId + || row.profile_kind !== binding.profile.profileKind + || row.profile_sha256 !== binding.profile.profileSha256 + || row.binding_sha256 !== binding.bindingSha256) { + throw new OhIntegrityError("Stored binding columns do not match their canonical envelope."); + } + return binding; +} + +function parseStoredPurgeRow(row: PurgeRow, expectedSpaceId: string): OhSpacePurgeReceiptV1 { + let value: unknown; + try { value = JSON.parse(row.receipt_json); } catch { throw new OhIntegrityError("A purge receipt is not JSON."); } + const receipt = parseOhSpacePurgeReceiptV1(value); + if (receipt === null || canonicalJson(receipt) !== row.receipt_json + || receipt.spaceId !== expectedSpaceId + || row.space_id !== receipt.spaceId + || row.binding_sha256 !== receipt.bindingSha256 + || row.prior_operation_sha256 !== receipt.priorHead.operationSha256 + || row.prior_sequence !== receipt.priorHead.sequence + || row.purged_at !== receipt.purgedAt + || row.receipt_sha256 !== receipt.receiptSha256) { + throw new OhIntegrityError("Stored purge columns do not match their canonical receipt."); + } + return receipt; +} function parseHead(row: SpaceRow): OhHeadV1 { const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256); @@ -205,25 +296,21 @@ export class OhSqliteStore { ensureSpace(): OhHeadV1 { this.#assertOpen(); - const purged = this.database.query( - "SELECT receipt_json FROM oh_space_purges WHERE space_id = ?", - ).get(this.spaceId); - if (purged !== null) { - let value: unknown; - try { value = JSON.parse(purged.receipt_json); } catch { throw new OhIntegrityError("A purge receipt is not JSON."); } - const receipt = parseOhSpacePurgeReceiptV1(value); - if (receipt === null || canonicalJson(receipt) !== purged.receipt_json) { - throw new OhIntegrityError("A stored purge receipt is invalid."); + return withImmediateTransaction(this.database, () => { + const purged = this.database.query( + `SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`, + ).get(this.spaceId); + if (purged !== null) { + throw new OhPurgedSpaceError(parseStoredPurgeRow(purged, this.spaceId)); } - throw new OhPurgedSpaceError(receipt); - } - const now = canonicalNow(); - this.database.query(`INSERT INTO oh_spaces( - space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, - records_sha256, sequence, created_at, updated_at - ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`) - .run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now); - return this.head(); + const now = canonicalNow(); + this.database.query(`INSERT INTO oh_spaces( + space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256, + records_sha256, sequence, created_at, updated_at + ) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`) + .run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now); + return this.head(); + }); } bind(bindingValue: OhStoreBindingV1): OhStoreBindingV1 { @@ -241,9 +328,11 @@ export class OhSqliteStore { binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow(), ); const row = this.database.query( - "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + `SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`, ).get(this.spaceId); - if (row === null || row.binding_json !== bindingJson) { + if (row === null) throw new OhIntegrityError("The persisted store binding disappeared."); + const persisted = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persisted) !== bindingJson) { throw new OhProfileError("The space is already bound to a different realm or profile."); } return binding; @@ -252,16 +341,10 @@ export class OhSqliteStore { binding(): OhStoreBindingV1 | null { this.#assertOpen(); const row = this.database.query( - "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + `SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`, ).get(this.spaceId); if (row === null) return null; - let value: unknown; - try { value = JSON.parse(row.binding_json); } catch { throw new OhIntegrityError("A store binding is not JSON."); } - const binding = parseOhStoreBindingV1(value); - if (binding === null || canonicalJson(binding) !== row.binding_json) { - throw new OhIntegrityError("A stored binding is invalid."); - } - return binding; + return parseStoredBindingRow(row, this.spaceId); } head(): OhHeadV1 { @@ -321,6 +404,58 @@ export class OhSqliteStore { return { graphRevisionSha256, records, recordsSha256 }; } + #assertCurrentHeadAuthority(head: OhHeadV1): void { + const summary = this.database.query<{ count: number; maximum: number | null; minimum: number | null }, [string]>( + `SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum + FROM oh_operations WHERE space_id = ?`, + ).get(this.spaceId); + if (summary === null || summary.count !== head.sequence + || (head.sequence === 0 && (summary.minimum !== null || summary.maximum !== null)) + || (head.sequence > 0 && (summary.minimum !== 1 || summary.maximum !== head.sequence))) { + throw new OhIntegrityError("The operation history does not exactly cover the current space head."); + } + if (head.sequence === 0) return; + if (head.operationSha256 === null) throw new OhIntegrityError("A nonempty space head has no operation digest."); + const row = this.database.query( + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`, + ).get(this.spaceId, head.sequence); + if (row === null) throw new OhIntegrityError("The current space head operation is missing."); + const operation = parseStoredOperationRow(row, { spaceId: this.spaceId, + operationSha256: head.operationSha256 }); + if (operation.sequence !== head.sequence + || operation.graphRevisionSha256 !== head.graphRevisionSha256 + || operation.recordsSha256 !== head.recordsSha256) { + throw new OhIntegrityError("The current space head differs from its canonical operation."); + } + } + + #assertOperationReachable(operation: OhOperationV1, head: OhHeadV1): void { + if (operation.sequence < 1 || operation.sequence > head.sequence) { + throw new OhIntegrityError("A stored idempotent operation is not reachable from the current head."); + } + const rows = this.database.query<{ + operation_sha256: string; parent_operation_sha256: string | null; sequence: number; + }, [string, number, number]>(`SELECT operation_sha256, parent_operation_sha256, sequence + FROM oh_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence`) + .all(this.spaceId, operation.sequence, head.sequence); + if (rows.length !== head.sequence - operation.sequence + 1) { + throw new OhIntegrityError("A stored idempotent operation has an incomplete path to the current head."); + } + let priorSha256: string | null = operation.parentOperationSha256; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row === undefined || row.sequence !== operation.sequence + index + || row.parent_operation_sha256 !== priorSha256 + || (index === 0 && row.operation_sha256 !== operation.operationSha256)) { + throw new OhIntegrityError("A stored idempotent operation is not on the current authority chain."); + } + priorSha256 = row.operation_sha256; + } + if (priorSha256 !== head.operationSha256) { + throw new OhIntegrityError("A stored idempotent operation does not reach the current head digest."); + } + } + #persist(operation: OhOperationV1): void { this.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence, operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256, @@ -390,18 +525,19 @@ export class OhSqliteStore { const changes = canonicalKnowledgeGraphChangesV1(input.changes); if (changes.length === 0 || changes.length > 8192) throw new TypeError("A commit needs 1 through 8192 changes."); return withImmediateTransaction(this.database, () => { + const head = this.head(); + this.#assertCurrentHeadAuthority(head); const duplicate = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? AND operation_id = ?", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND operation_id = ?`, ).get(this.spaceId, operationId); if (duplicate !== null) { - const existing = parseOhOperationV1(JSON.parse(duplicate.operation_json)); - if (existing === null) throw new OhIntegrityError("The stored idempotent operation is invalid."); + const existing = parseStoredOperationRow(duplicate, { operationId, spaceId: this.spaceId }); + this.#assertOperationReachable(existing, head); if (existing.actorId !== actorId || canonicalJson(existing.changes) !== canonicalJson(changes)) { throw new OhConflictError("The operation ID is already bound to different content."); } return existing; } - const head = this.head(); if (head.generation !== input.expectedHead.generation || head.operationSha256 !== input.expectedHead.operationSha256) { throw new OhConflictError("The expected head does not match the current space head."); @@ -422,16 +558,20 @@ export class OhSqliteStore { const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation."); return withImmediateTransaction(this.database, () => { + const head = this.head(); + this.#assertCurrentHeadAuthority(head); const duplicate = this.database.query( - "SELECT operation_json FROM oh_operations WHERE operation_sha256 = ?", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE operation_sha256 = ?`, ).get(operation.operationSha256); if (duplicate !== null) { - if (canonicalJson(JSON.parse(duplicate.operation_json)) !== canonicalJson(operation)) { + const existing = parseStoredOperationRow(duplicate, { operationSha256: operation.operationSha256, + spaceId: this.spaceId }); + this.#assertOperationReachable(existing, head); + if (canonicalJson(existing) !== canonicalJson(operation)) { throw new OhIntegrityError("An operation digest is bound to different bytes."); } return { imported: false, operation }; } - const head = this.head(); if (operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) { throw new OhConflictError("The imported operation does not extend the local head."); } @@ -451,13 +591,10 @@ export class OhSqliteStore { if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative."); const boundedLimit = normalizeLimit(limit); const rows = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`, ).all(this.spaceId, afterSequence, boundedLimit); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null || canonicalJson(operation) !== row.operation_json) throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } #headAt(reference: OhHeadRefV1): OhHeadV1 { @@ -465,16 +602,12 @@ export class OhSqliteStore { if (parsed === null) throw new TypeError("Invalid Oh head reference."); if (parsed.sequence === 0) return emptyOhHeadV1(); const row = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence = ?", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`, ).get(this.spaceId, parsed.sequence); if (row === null) throw new OhConflictError("The requested head is not present in this space."); - let value: unknown; - try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - if (operation.operationSha256 !== parsed.operationSha256) { + const operation = parseStoredOperationRow(row, { spaceId: this.spaceId }); + if (operation.spaceId !== this.spaceId || operation.sequence !== parsed.sequence + || operation.operationSha256 !== parsed.operationSha256) { throw new OhConflictError("The requested sequence identifies a different operation head."); } return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256, @@ -497,16 +630,10 @@ export class OhSqliteStore { const target = options.head === undefined ? current : this.#headAt(options.head); if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space."); const rows = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? AND sequence <= ? ORDER BY sequence", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? AND sequence <= ? ORDER BY sequence`, ).all(this.spaceId, target.sequence); - const operations = rows.map((row) => { - let value: unknown; - try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } - if (canonicalJson(value) !== row.operation_json) throw new OhIntegrityError("A stored operation is not canonical JSON."); - const operation = parseOhOperationV1(value); - if (operation === null) throw new OhIntegrityError("A stored operation is invalid."); - return operation; - }); + const operations = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords); if (snapshot.head.operationSha256 !== target.operationSha256 || snapshot.head.recordsSha256 !== target.recordsSha256) { @@ -532,33 +659,25 @@ export class OhSqliteStore { throw new OhConflictError("The change-feed bounds do not identify one local history prefix."); } const rows = this.database.query( - `SELECT operation_json FROM oh_operations + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, ).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1); - const parsed = rows.map((row) => { - let value: unknown; - try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } - const operation = parseOhOperationV1(value); - if (operation === null || canonicalJson(operation) !== row.operation_json) { - throw new OhIntegrityError("A stored operation is invalid."); - } - return operation; - }); + const parsed = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); + if (parsed.length > limit + 1) throw new OhIntegrityError("The change feed exceeded its requested page bound."); const hasMore = parsed.length > limit; const operations = parsed.slice(0, limit); - const first = operations[0]; - if (first !== undefined && (first.sequence !== fromHead.sequence + 1 - || first.parentOperationSha256 !== fromHead.operationSha256)) { - throw new OhIntegrityError("The change feed does not extend its cursor."); - } - for (let index = 1; index < operations.length; index += 1) { - const prior = operations[index - 1] as OhOperationV1; - const operation = operations[index] as OhOperationV1; + let prior: OhHeadRefV1 = fromHead; + for (const operation of parsed) { if (operation.sequence !== prior.sequence + 1 || operation.parentOperationSha256 !== prior.operationSha256) { throw new OhIntegrityError("The change feed contains a gap or fork."); } + prior = { operationSha256: operation.operationSha256, sequence: operation.sequence }; + } + if (!hasMore && (prior.sequence !== through.sequence + || prior.operationSha256 !== through.operationSha256)) { + throw new OhIntegrityError("The change feed does not reach its pinned through head."); } const last = operations.at(-1); const to = last === undefined @@ -645,13 +764,10 @@ export class OhSqliteStore { log(limit = 50): readonly OhOperationV1[] { this.#assertOpen(); const rows = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence DESC LIMIT ?", + `SELECT ${OPERATION_COLUMNS} FROM oh_operations + WHERE space_id = ? ORDER BY sequence DESC LIMIT ?`, ).all(this.spaceId, normalizeLimit(limit)); - return rows.map((row) => { - const operation = parseOhOperationV1(JSON.parse(row.operation_json)); - if (operation === null) throw new OhIntegrityError("The stored operation is invalid."); - return operation; - }); + return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); } searchKeyword(query: string, limit = 20): readonly OhKeywordSearchResultV1[] { @@ -707,15 +823,8 @@ export class OhSqliteStore { "SELECT count(*) AS count FROM oh_operations WHERE space_id = ?", ).get(this.spaceId)?.count ?? 0; const operations: readonly OhOperationV1[] = this.database.query( - "SELECT operation_json FROM oh_operations WHERE space_id = ? ORDER BY sequence", - ).all(this.spaceId).map((row) => { - let value: unknown; - try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); } - if (canonicalJson(value) !== row.operation_json) throw new OhIntegrityError("A stored operation is not canonical JSON."); - const parsed = parseOhOperationV1(value); - if (parsed === null) throw new OhIntegrityError("A stored operation is invalid."); - return parsed; - }); + `SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? ORDER BY sequence`, + ).all(this.spaceId).map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId })); if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification."); return this.#verifyOperations(operations); } @@ -723,11 +832,14 @@ export class OhSqliteStore { #verifyOperations(operations: readonly OhOperationV1[]): OhReplayVerificationV1 { const records = new Map(); const materializedBy = new Map>(); + const operationIds = new Set(); let head: OhHeadV1 = { generation: 0, graphRevisionSha256: null, operationSha256: null, recordsSha256: EMPTY_RECORDS_SHA256, sequence: 0, v: 1 }; for (const operation of operations) { if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1 - || operation.parentOperationSha256 !== head.operationSha256) throw new OhIntegrityError("Operation replay chain is broken."); + || operation.parentOperationSha256 !== head.operationSha256 + || operationIds.has(operation.operationId)) throw new OhIntegrityError("Operation replay chain is broken."); + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") { records.set(change.record.key, change.record); @@ -813,9 +925,13 @@ export class OhSqliteStore { } return withImmediateTransaction(this.database, () => { const row = this.database.query( - "SELECT binding_json FROM oh_space_bindings WHERE space_id = ?", + `SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`, ).get(this.spaceId); - if (row === null || row.binding_json !== canonicalJson(binding)) { + if (row === null) { + throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); + } + const persistedBinding = parseStoredBindingRow(row, this.spaceId); + if (canonicalJson(persistedBinding) !== canonicalJson(binding)) { throw new OhProfileError("Whole-space purge requires the exact persisted store binding."); } const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt }); @@ -835,6 +951,31 @@ export class OhSqliteStore { this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId); this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId); + const directTables = ["oh_spaces", "oh_space_bindings", "oh_operations", "oh_records", + "oh_dependencies", "oh_search_documents", "oh_sync_outbox", "oh_sync_state"] as const; + for (const table of directTables) { + const count = this.database.query<{ count: number }, [string]>( + `SELECT count(*) AS count FROM ${table} WHERE space_id = ?`, + ).get(this.spaceId)?.count; + if (count !== 0) throw new OhIntegrityError(`Space purge left rows in ${table}.`); + } + const operationRecords = this.database.query<{ count: number }, [string]>(`SELECT count(*) AS count + FROM oh_operation_records AS materialized JOIN oh_operations AS operation + ON operation.operation_sha256 = materialized.operation_sha256 + WHERE operation.space_id = ?`).get(this.spaceId)?.count; + const searchRows = this.database.query<{ count: number }, [string]>( + "SELECT count(*) AS count FROM oh_search_fts WHERE space_id = ?", + ).get(this.spaceId)?.count; + if (operationRecords !== 0 || searchRows !== 0) { + throw new OhIntegrityError("Space purge left derived private payload rows."); + } + const receiptRow = this.database.query( + `SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`, + ).get(this.spaceId); + if (receiptRow === null + || canonicalJson(parseStoredPurgeRow(receiptRow, this.spaceId)) !== canonicalJson(receipt)) { + throw new OhIntegrityError("The stored purge receipt differs from the requested purge."); + } return receipt; }); } diff --git a/src/store.test.ts b/src/store.test.ts index d17001c..d45acb5 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -17,6 +17,7 @@ import { replayOhOperationsV1, transitionOhSnapshotV1, verifyOhDependencyClosureV1, + verifyOhDependencyClosureAgainstV1, } from "./store"; describe("runtime-neutral Oh store contracts", () => { @@ -60,6 +61,11 @@ describe("runtime-neutral Oh store contracts", () => { expect(closure.records.map(({ key }) => key)).toEqual([child.key, parent.key].sort()); expect(parseOhDependencyClosureV1(closure)).toEqual(closure); expect(verifyOhDependencyClosureV1(closure)).toEqual({ closure, ok: true }); + expect(verifyOhDependencyClosureAgainstV1(closure, { binding, head: snapshot.head })) + .toEqual({ closure, ok: true, verification: "expected-authority-and-head" }); + expect(verifyOhDependencyClosureAgainstV1(closure, { binding, + head: { ...snapshot.head, recordsSha256: "a".repeat(64) as typeof snapshot.head.recordsSha256 } })) + .toEqual({ ok: false, reason: "head-mismatch" }); expect(parseOhDependencyClosureV1({ ...closure, records: [...closure.records, unrelated] })).toBeNull(); expect(parseOhDependencyClosureV1({ ...closure, closureSha256: "a".repeat(64) })).toBeNull(); @@ -98,4 +104,18 @@ describe("runtime-neutral Oh store contracts", () => { expect(canonicalJson((await authority.store.snapshot()).records[0]?.value)).toBe('{"name":"Ada"}'); await authority.store.close(); }); + + test("rejects duplicate operation IDs during generic replay", () => { + const first = transitionOhSnapshotV1({ actorId: "agent.test", + changes: [{ kind: "put", record: createKnowledgeGraphRecordV1({ dependencies: [], + key: "entity:first", kind: "entity", v: 1, value: { name: "First" } }), v: 1 }], + instant: "2026-08-29T12:00:00.000Z", operationId: "op_duplicate", + snapshot: { head: emptyOhHeadV1(), records: [], v: 1 }, spaceId: "duplicate" }); + const second = transitionOhSnapshotV1({ actorId: "agent.test", + changes: [{ kind: "put", record: createKnowledgeGraphRecordV1({ dependencies: [], + key: "entity:second", kind: "entity", v: 1, value: { name: "Second" } }), v: 1 }], + instant: "2026-08-29T12:01:00.000Z", operationId: "op_duplicate", + snapshot: first.snapshot, spaceId: "duplicate" }); + expect(() => replayOhOperationsV1("duplicate", [first.operation, second.operation])).toThrow("replay chain"); + }); }); diff --git a/src/store.ts b/src/store.ts index d133c81..b58189d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -11,8 +11,9 @@ import { } from "./canonical"; import { OH_CONTRACT_MANIFEST_V1, - type OhRecordCodecRegistry, + OhRecordCodecRegistry, } from "./contract"; +export { OhRecordCodecRegistry } from "./contract"; import { canonicalKnowledgeGraphChangesV1, createKnowledgeGraphRecordV1, @@ -366,14 +367,17 @@ export function replayOhOperationsV1( throw new TypeError("Invalid operation replay input."); } const records = new Map(); + const operationIds = new Set(); let head = emptyOhHeadV1(); for (const value of values) { const operation = parseOhOperationV1(value); if (operation === null || operation.spaceId !== parsedSpaceId || operation.sequence !== head.sequence + 1 - || operation.parentOperationSha256 !== head.operationSha256) { + || operation.parentOperationSha256 !== head.operationSha256 + || operationIds.has(operation.operationId)) { throw new OhIntegrityError("Operation replay chain is broken."); } + operationIds.add(operation.operationId); for (const change of operation.changes) { if (change.kind === "put") records.set(change.record.key, change.record); else { @@ -482,14 +486,18 @@ function closureRecords( available: ReadonlyMap, roots: readonly string[], maximumRecords: number, + maximumBytes: number = OH_DEPENDENCY_CLOSURE_LIMITS_V1.bytes - 64 * 1024, ): readonly KnowledgeGraphRecordV1[] { const selected = new Map(); const pending = [...roots]; + let selectedBytes = 0; while (pending.length > 0) { const key = pending.pop() as string; if (selected.has(key)) continue; const record = available.get(key); if (record === undefined) throw new OhDependencyError(`Dependency closure record ${key} is missing.`); + selectedBytes += Buffer.byteLength(canonicalJson(record), "utf8") + 1; + if (selectedBytes > maximumBytes) throw new RangeError("Dependency closure exceeds its canonical byte bound."); selected.set(key, record); if (selected.size > maximumRecords) throw new RangeError("Dependency closure exceeds its record bound."); pending.push(...record.dependencies); @@ -562,6 +570,26 @@ export function verifyOhDependencyClosureV1(value: unknown): return closure === null ? { ok: false, reason: "invalid-closure" } : { closure, ok: true }; } +/** + * Strong adoption check. Unlike structural self-verification, this also binds + * the capsule to the exact store binding and head selected by trusted host code. + */ +export function verifyOhDependencyClosureAgainstV1(value: unknown, expected: Readonly<{ + binding: OhStoreBindingV1; + head: OhHeadV1; +}>): + | Readonly<{ closure: OhDependencyClosureV1; ok: true; verification: "expected-authority-and-head" }> + | Readonly<{ ok: false; reason: "binding-mismatch" | "head-mismatch" | "invalid-closure" | "invalid-expectation" }> { + const binding = parseOhStoreBindingV1(expected.binding); + const head = parseOhHeadV1(expected.head); + if (binding === null || head === null) return { ok: false, reason: "invalid-expectation" }; + const closure = parseOhDependencyClosureV1(value); + if (closure === null) return { ok: false, reason: "invalid-closure" }; + if (closure.binding.bindingSha256 !== binding.bindingSha256) return { ok: false, reason: "binding-mismatch" }; + if (canonicalJson(closure.head) !== canonicalJson(head)) return { ok: false, reason: "head-mismatch" }; + return { closure, ok: true, verification: "expected-authority-and-head" }; +} + export function createOhSpacePurgeReceiptV1(input: Readonly<{ binding: OhStoreBindingV1; priorHead: OhHeadV1; diff --git a/tests/node-portable-types.ts b/tests/node-portable-types.ts new file mode 100644 index 0000000..5742306 --- /dev/null +++ b/tests/node-portable-types.ts @@ -0,0 +1,17 @@ +import { + OhRecordCodecRegistry, + type OhStoreV1, +} from "@hraness/oh/store"; +import { + createOhMemoryAgentV1, + type OhMemoryFacadeOptionsV1, + type OhMemoryRememberReceiptV1, +} from "@hraness/oh/experimental/memory"; + +// Compile-only consumer fixture for the portable public entrypoints. The Node +// runtime exercise lives in node-portable.mjs; this catches declaration drift. +export const portableCodecs = new OhRecordCodecRegistry(); +export const portableMemoryFactory: typeof createOhMemoryAgentV1 = createOhMemoryAgentV1; +export type PortableMemoryOptions = OhMemoryFacadeOptionsV1; +export type PortableMemoryReceipt = OhMemoryRememberReceiptV1; +export type PortableStore = OhStoreV1; diff --git a/tests/node-portable.mjs b/tests/node-portable.mjs index 3041095..37b975d 100644 --- a/tests/node-portable.mjs +++ b/tests/node-portable.mjs @@ -4,10 +4,68 @@ import assert from "node:assert/strict"; // catches an export that accidentally makes Bun-only modules reachable. const store = await import("@hraness/oh/store"); const libsql = await import("@hraness/oh/libsql"); +const memory = await import("@hraness/oh/experimental/memory"); +const projection = await import("@hraness/oh/projection"); assert.equal(typeof store.createOhStoreBindingV1, "function"); +assert.equal(typeof store.OhRecordCodecRegistry, "function"); assert.equal(typeof store.OhSemanticBundleIngressV1, "function"); assert.equal(typeof libsql.createOhLibSqlStoreAuthorityV1, "function"); +assert.equal(typeof memory.createOhMemoryAgentV1, "function"); assert.equal(store.OH_WORKING_STORE_PROFILE_V1.profileKind, "working"); assert.equal(store.OH_WORKING_STORE_PROFILE_V1.capabilities.operationReplication, false); assert.equal(store.OH_WORKING_STORE_PROFILE_V1.capabilities.wholeSpacePurge, true); + +function emptyStore(profile, realmId, spaceId) { + const binding = store.createOhStoreBindingV1({ profile, realmId, spaceId, v: 1 }); + const head = store.emptyOhHeadV1(); + return { + binding, + async head() { return head; }, + async snapshot(options = {}) { + assert.equal(options.maximumRecords, memory.OH_MEMORY_LIMITS_V1.maximumRecordsPerLane); + return { head, records: [], v: 1 }; + }, + }; +} + +const canonicalStore = emptyStore(store.OH_CANONICAL_STORE_PROFILE_V1, + "realm:node-canonical", "node-canonical"); +const workingStore = emptyStore(store.OH_WORKING_STORE_PROFILE_V1, + "realm:node-working", "node-working"); +const lane = projection.ohProjectionVariableV1("lane"); +const key = projection.ohProjectionVariableV1("key"); +const kind = projection.ohProjectionVariableV1("kind"); +const digest = projection.ohProjectionVariableV1("digest"); +const record = projection.createOhProjectionLiteralV1({ + relation: "memory.record", terms: [lane, key, kind, digest], +}); +const visible = projection.createOhProjectionLiteralV1({ + relation: "memory.visible", terms: [lane, key, digest], +}); +const rulePack = projection.createOhProjectionRulePackV1({ + rulePackId: "memory.node-visible", rulePackRevision: 1, + rules: [projection.createOhProjectionRuleV1({ + body: [record], head: visible, ruleId: "memory.node-visible", + })], +}); +const query = projection.createOhProjectionQueryV1({ + find: ["lane", "key", "digest"], limit: 10, queryId: "memory.node-visible", + where: [visible], +}); +const agent = await memory.createOhMemoryAgentV1({ + actorId: "node.memory-agent", + canonical: { authorityId: "node.canonical", + expectedBindingSha256: canonicalStore.binding.bindingSha256, + expectedHead: await canonicalStore.head(), store: canonicalStore }, + monotonicNow: () => 0, + now: () => new Date("2026-08-29T12:00:00.000Z"), + programs: [{ programId: "memory.node-visible", purpose: "node.portability", query, rulePack }], + working: { authorityId: "node.working", codecs: new store.OhRecordCodecRegistry(), + expectedBindingSha256: workingStore.binding.bindingSha256, store: workingStore }, +}); +const result = await agent.query({ programId: "memory.node-visible", v: 1 }); +assert.equal(result.authority, "derived"); +assert.equal(result.identity.purpose, "node.portability"); +assert.deepEqual(result.rows, []); +assert.equal(Object.isFrozen(result.rows), true); diff --git a/tests/public-surface.test.ts b/tests/public-surface.test.ts index 80b150b..4fee5e3 100644 --- a/tests/public-surface.test.ts +++ b/tests/public-surface.test.ts @@ -41,6 +41,10 @@ const schemaFiles = [ "spec/v1/schema-revision.schema.json", "spec/v1/operation.schema.json", "spec/v1/sync-bundle.schema.json", + "spec/v1/projection-rule-pack.schema.json", + "spec/v1/projection-query.schema.json", + "spec/v1/projection-identity.schema.json", + "spec/v1/projection-result.schema.json", ] as const; const publicSourceEntries = [ @@ -148,7 +152,7 @@ describe("public identity and documentation", () => { ]); expect(readme.startsWith(`# ${tagline}\n`)).toBe(true); expect(packageJson.name).toBe("@hraness/oh"); - expect(packageJson.version).toBe("0.1.1"); + expect(packageJson.version).toBe("0.2.0"); expect(packageJson.description).toBe(tagline); expect(packageJson.homepage).toBe("https://oh.computer"); expect(packageJson.license).toBe("MIT"); @@ -241,7 +245,8 @@ describe("versioned public contract", () => { const manifest = await json("spec/manifest.json"); const version = (manifest.versions as readonly Record[])[0] as Record; const claims = [version.contract, version.embeddingProfile, version.ontology, version.specification, - ...(Array.isArray(version.schemas) ? version.schemas : [])]; + ...collectStringLeaves(version.memory), + ...collectStringLeaves(version.projection), ...(Array.isArray(version.schemas) ? version.schemas : [])]; expect(claims.length).toBeGreaterThan(4); for (const claim of claims) { expect(typeof claim).toBe("string"); @@ -278,6 +283,7 @@ describe("versioned public contract", () => { const exports = packageJson.exports as Record; expect(Object.keys(exports).sort()).toEqual([ ".", + "./experimental/memory", "./experimental/projection-suss", "./libsql", "./package.json", @@ -319,6 +325,49 @@ describe("versioned public contract", () => { expect(await json("spec/v1/embedding-profile.json")).toEqual(OH_EMBEDDING_PROFILE_V1); }); + test("discovers the complete projection exchange surface", async () => { + const manifest = await json("spec/manifest.json"); + const version = (manifest.versions as readonly Record[])[0] as Record; + expect(version.projection).toEqual({ + identitySchema: "./v1/projection-identity.schema.json", + querySchema: "./v1/projection-query.schema.json", + resultSchema: "./v1/projection-result.schema.json", + rulePackSchema: "./v1/projection-rule-pack.schema.json", + specification: "./v1/projection.md", + }); + + const identity = await json("spec/v1/projection-identity.schema.json"); + const identityProperties = identity.properties as Record; + expect(Object.keys(identityProperties).sort()).toEqual([ + "contractSha256", "datasetSha256", "engineSha256", "evaluationSha256", "projectionSha256", + "querySha256", "rulePackSha256", "semantics", "snapshotSha256", "v", + ]); + + const result = await json("spec/v1/projection-result.schema.json"); + const definitions = result.$defs as Record>; + const evaluation = definitions.evaluation?.properties as Record; + const row = definitions.row?.properties as Record; + const stats = definitions.stats?.properties as Record; + expect(Object.keys(evaluation).sort()).toEqual([ + "maximumDerivedTuples", "maximumProofDepth", "maximumProofNodes", "maximumResultBytes", + "maximumRounds", "maximumTotalProofNodes", "maximumWorkUnits", "v", + ]); + expect(Object.keys(row).sort()).toEqual(["proofs", "proofsTruncated", "supportCount", "v", "values"]); + expect(Object.keys(stats).sort()).toEqual([ + "baseFacts", "derivedFacts", "proofNodes", "proofsTruncated", "queryMatches", "relations", + "rounds", "truncated", "truncationReasons", "v", "workUnits", + ]); + }); + + test("discovers the experimental composite memory boundary", async () => { + const manifest = await json("spec/manifest.json"); + const version = (manifest.versions as readonly Record[])[0] as Record; + expect(version.memory).toEqual({ specification: "./v1/memory.md" }); + const memory = await readFile(join(root, "spec/v1/memory.md"), "utf8"); + expect(memory).toContain("One kernel, two authorities"); + expect(memory).toContain("It does not sync the working operation chain"); + }); + test("keeps every JSON Schema parseable, versioned, and locally closed", async () => { for (const path of schemaFiles) { const schema = await json(path); diff --git a/tsconfig.node-portable.json b/tsconfig.node-portable.json new file mode 100644 index 0000000..4608d41 --- /dev/null +++ b/tsconfig.node-portable.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["tests/node-portable-types.ts"] +}