diff --git a/README.md b/README.md index f169355..1032237 100644 --- a/README.md +++ b/README.md @@ -40,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.2.1 +bun add --global github:hraness/oh#v0.2.2 oh --help ``` @@ -100,7 +100,7 @@ For a project dependency, pin the same immutable release in `package.json`: ```json { "dependencies": { - "@hraness/oh": "github:hraness/oh#v0.2.1" + "@hraness/oh": "github:hraness/oh#v0.2.2" } } ``` @@ -162,7 +162,7 @@ import { createClient } from "@libsql/client"; import { bootstrapOhLibSqlAuthorityV1, createOhLibSqlStoreAuthorityV1, - openExistingOhLibSqlStoreAuthorityV1, + purgeOhLibSqlWorkingSpaceV1, } from "@hraness/oh/libsql"; import { OH_WORKING_STORE_PROFILE_V1 } from "@hraness/oh/store"; @@ -188,20 +188,19 @@ const authority = await createOhLibSqlStoreAuthorityV1(runtimeClient, { const store = authority.store; console.log(await store.head()); -// A separately held purge worker can fail closed unless the exact binding -// already exists. Opening performs reads only; it cannot create a space. +// A separately held purge worker either purges the exact existing binding or +// writes an empty-head tombstone when creation never completed. It cannot +// create a space or binding, and a delayed creator cannot resurrect custody. const purgeClient = createClient({ authToken: process.env.OH_PURGE_TOKEN!, url: process.env.OH_DATABASE_URL!, }); -const existing = await openExistingOhLibSqlStoreAuthorityV1(purgeClient, { +await purgeOhLibSqlWorkingSpaceV1(purgeClient, { + closeClient: true, profile: OH_WORKING_STORE_PROFILE_V1, realmId: "tenant:example/thread:research", spaceId: "thread:research", }); -await existing.host.purgeWorkingSpace({}); -await existing.store.close(); -purgeClient.close(); ``` The working profile disables operation replication. Dependency-closure export @@ -467,7 +466,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.2.1 tag at +Install hraness/oh and its Oh Agent Skill from the immutable v0.2.2 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/dist/cli.d.ts b/dist/cli.d.ts index f491c88..23b8b75 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.2.1"; +export declare const OH_PACKAGE_VERSION: "0.2.2"; 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 599832a..6f8aa58 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -3072,7 +3072,7 @@ class Oh { // src/cli.ts import { readFile } from "fs/promises"; -var OH_PACKAGE_VERSION = "0.2.1"; +var OH_PACKAGE_VERSION = "0.2.2"; var KNOWN_OPTIONS = new Set([ "actor", "after", diff --git a/dist/libsql.d.ts b/dist/libsql.d.ts index 832f57b..e20b2dc 100644 --- a/dist/libsql.d.ts +++ b/dist/libsql.d.ts @@ -1,5 +1,5 @@ import { type Sha256Hex } from "./canonical"; -import { type OhStoreAuthorityV1, type OhStoreProfileV1 } from "./store"; +import { type OhSpacePurgeReceiptV1, type OhStoreAuthorityV1, type OhStoreProfileV1 } from "./store"; export type OhLibSqlValueV1 = ArrayBuffer | Date | Uint8Array | bigint | boolean | null | number | string; export type OhLibSqlStatementV1 = Readonly<{ args?: readonly OhLibSqlValueV1[]; @@ -45,4 +45,13 @@ export declare function createOhLibSqlStoreAuthorityV1(client: OhLibSqlClientV1, * must fail closed instead of acquiring space-creation authority. */ export declare function openExistingOhLibSqlStoreAuthorityV1(client: OhLibSqlClientV1, options?: OhLibSqlStoreAuthorityOptionsV1): Promise; +/** + * Purges an existing working authority or atomically fences its exact binding + * when creation never completed. The empty-space receipt prevents a delayed + * creator from resurrecting abandoned custody without granting the purge + * credential permission to create a space or binding. + */ +export declare function purgeOhLibSqlWorkingSpaceV1(client: OhLibSqlClientV1, options?: OhLibSqlStoreAuthorityOptionsV1 & Readonly<{ + purgedAt?: string; +}>): 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 index a7e3433..3e6e0a8 100644 --- a/dist/libsql.d.ts.map +++ b/dist/libsql.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"libsql.d.ts","sourceRoot":"","sources":["../src/libsql.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAMrB,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;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;EASpC,CAAC;AAuVH,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,CAsBxE;AAijCD,6EAA6E;AAC7E,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAS7B;AAED;;;;GAIG;AACH,wBAAsB,oCAAoC,CACxD,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAS7B"} \ No newline at end of file +{"version":3,"file":"libsql.d.ts","sourceRoot":"","sources":["../src/libsql.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAMrB,OAAO,EAuBL,KAAK,qBAAqB,EAC1B,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;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;EASpC,CAAC;AAyVH,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,CAsBxE;AA4jCD,6EAA6E;AAC7E,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAS7B;AAED;;;;GAIG;AACH,wBAAsB,oCAAoC,CACxD,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAS7B;AAED;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAC/C,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,+BAA+B,GAAG,QAAQ,CAAC;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAM,GAC9E,OAAO,CAAC,qBAAqB,CAAC,CA8GhC"} \ No newline at end of file diff --git a/dist/libsql.js b/dist/libsql.js index a38bbb1..8d204f6 100644 --- a/dist/libsql.js +++ b/dist/libsql.js @@ -987,6 +987,8 @@ var PURGE_ROW_SELECT = `SELECT space_id, binding_sha256, prior_operation_sha256, 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 SPACE_PURGE_PROOF_SELECT = `SELECT generation, graph_revision_sha256, head_operation_sha256, + records_sha256, sequence, contract_id FROM oh_authority_spaces 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)) @@ -1399,6 +1401,41 @@ async function requireExistingSpace(client, binding) { } parseHeadRow(spaceRow); } +var PURGE_PAYLOAD_TABLES = [ + "oh_authority_spaces", + "oh_authority_bindings", + "oh_authority_operations", + "oh_authority_operation_records", + "oh_authority_records", + "oh_authority_dependencies" +]; +async function assertRemotePurgeComplete(client, binding, expected) { + const results = await client.batch([ + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }, + ...PURGE_PAYLOAD_TABLES.map((table) => ({ + sql: `SELECT count(*) AS count FROM ${table} WHERE space_id = ?`, + args: [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, binding.spaceId, binding.bindingSha256)) !== canonicalJson(expected)) { + throw new OhIntegrityError("The remote purge receipt differs from the requested purge."); + } + for (let index = 0;index < PURGE_PAYLOAD_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 ${PURGE_PAYLOAD_TABLES[index]}.`); + } + } + const orphanRow = results[PURGE_PAYLOAD_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."); + } +} class OhLibSqlStoreV1 { binding; @@ -2319,42 +2356,7 @@ class OhLibSqlStoreV1 { }; } 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."); - } + await assertRemotePurgeComplete(this.#client, this.binding, expected); } async purgeWorkingSpace(purgedAt) { this.#assertOpen(); @@ -2516,7 +2518,143 @@ async function openExistingOhLibSqlStoreAuthorityV1(client, options = {}) { await requireExistingSpace(client, binding); return bindOhLibSqlStoreAuthorityV1(client, binding, profile, options.closeClient ?? false); } +async function purgeOhLibSqlWorkingSpaceV1(client, options = {}) { + const closeClient = options.closeClient ?? false; + try { + const profile = parseOhStoreProfileV1(options.profile ?? OH_WORKING_STORE_PROFILE_V1); + if (profile === null) + throw new TypeError("Invalid libSQL store profile."); + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ + profile, + realmId: options.realmId ?? `realm:${spaceId}`, + spaceId, + v: 1 + }); + const purgedAt = options.purgedAt ?? canonicalNow(); + await verifyAuthoritySchema(client); + for (let attempt = 0;attempt < 3; attempt += 1) { + const proof = await client.batch([ + { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }, + { sql: SPACE_PURGE_PROOF_SELECT, args: [binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] } + ], "read"); + if (proof.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete purge proof."); + } + const [bindingResult, spaceResult, purgeResult] = proof; + const existingPurge = purgeResult.rows[0]; + if (existingPurge !== undefined) { + const receipt2 = parsePurgeReceiptRow(existingPurge, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, receipt2); + return receipt2; + } + const bindingRow = bindingResult.rows[0]; + const spaceRow = spaceResult.rows[0]; + if (bindingRow === undefined !== (spaceRow === undefined)) { + throw new OhIntegrityError("The remote authority has only half of its space binding."); + } + if (bindingRow !== undefined && spaceRow !== undefined) { + const persisted2 = parseBindingRow(bindingRow, binding.spaceId); + if (canonicalJson(persisted2) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is bound to a different realm or profile."); + } + if (rowValue(spaceRow, "contract_id", 5) !== OH_CONTRACT_MANIFEST_V1.contractId) { + throw new OhIntegrityError("The existing remote space uses a different Oh contract."); + } + parseHeadRow(spaceRow); + const authority = new OhLibSqlStoreV1(client, binding, false); + return await authority.purgeWorkingSpace(purgedAt); + } + const receipt = createOhSpacePurgeReceiptV1({ + binding, + priorHead: emptyOhHeadV1(), + purgedAt + }); + const receiptJson = canonicalJson(receipt); + try { + await client.batch([ + { + sql: `INSERT INTO oh_authority_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + SELECT ?, ?, NULL, 0, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + AND NOT EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, + args: [ + binding.spaceId, + binding.bindingSha256, + receipt.purgedAt, + receipt.receiptSha256, + receiptJson, + binding.spaceId, + binding.spaceId + ] + }, + { + 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_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_dependencies 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 NOT EXISTS (SELECT 1 FROM oh_authority_purges + WHERE space_id = ? AND receipt_sha256 = ?)`, + args: [ + binding.spaceId, + binding.spaceId, + binding.spaceId, + binding.spaceId, + binding.spaceId, + binding.spaceId, + binding.spaceId, + receipt.receiptSha256 + ] + } + ], "write"); + } catch (error) { + const recovery = await client.batch([ + { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }, + { sql: SPACE_PURGE_PROOF_SELECT, args: [binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] } + ], "read"); + if (recovery.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete purge recovery proof."); + } + const raced = recovery[2]?.rows[0]; + if (raced !== undefined) { + const persisted2 = parsePurgeReceiptRow(raced, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, persisted2); + return persisted2; + } + if (recovery[0]?.rows[0] !== undefined || recovery[1]?.rows[0] !== undefined) + continue; + throw error; + } + const persisted = await queryOne(client, { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }); + if (persisted === null) + continue; + const exact = parsePurgeReceiptRow(persisted, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, exact); + return exact; + } + throw new OhConflictError("The remote working space changed repeatedly while fencing purge."); + } finally { + if (closeClient) + client.close?.(); + } +} export { + purgeOhLibSqlWorkingSpaceV1, openExistingOhLibSqlStoreAuthorityV1, createOhLibSqlStoreAuthorityV1, bootstrapOhLibSqlAuthorityV1, diff --git a/package.json b/package.json index f2cfbd2..5d4ed47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/oh", - "version": "0.2.1", + "version": "0.2.2", "description": "open-source tools for agentic research", "type": "module", "license": "MIT", diff --git a/site/package.json b/site/package.json index 1a4f4e5..3a920c2 100644 --- a/site/package.json +++ b/site/package.json @@ -1,6 +1,6 @@ { "name": "oh-site", - "version": "0.2.1", + "version": "0.2.2", "private": true, "packageManager": "bun@1.3.14", "engines": { diff --git a/site/public/spec/v1/store.md b/site/public/spec/v1/store.md index f5607dc..41ad5b7 100644 --- a/site/public/spec/v1/store.md +++ b/site/public/spec/v1/store.md @@ -76,6 +76,13 @@ purged, or differently bound space and never inserts, updates, or deletes data during open. A provider credential can therefore omit space and binding creation while retaining only the data actions required by its later task. +`purgeOhLibSqlWorkingSpaceV1` is the one-shot lifecycle-worker boundary. It +purges an existing exact working binding or atomically writes an empty-head +purge receipt when creation never completed. Creation and absent-space fencing +race in the same authority transaction: whichever wins, the result converges +to one complete purge receipt, and a delayed creator cannot resurrect the +space. The credential still needs no permission to create spaces or bindings. + 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 diff --git a/skills/oh/SKILL.md b/skills/oh/SKILL.md index fbb6008..1068630 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.2.1` from the immutable `v0.2.1` GitHub +The supported CLI is `@hraness/oh@0.2.2` from the immutable `v0.2.2` GitHub tag. It requires Bun 1.3.14 or newer. The versioned contract is published at . diff --git a/spec/v1/store.md b/spec/v1/store.md index f5607dc..41ad5b7 100644 --- a/spec/v1/store.md +++ b/spec/v1/store.md @@ -76,6 +76,13 @@ purged, or differently bound space and never inserts, updates, or deletes data during open. A provider credential can therefore omit space and binding creation while retaining only the data actions required by its later task. +`purgeOhLibSqlWorkingSpaceV1` is the one-shot lifecycle-worker boundary. It +purges an existing exact working binding or atomically writes an empty-head +purge receipt when creation never completed. Creation and absent-space fencing +race in the same authority transaction: whichever wins, the result converges +to one complete purge receipt, and a delayed creator cannot resurrect the +space. The credential still needs no permission to create spaces or bindings. + 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 diff --git a/src/cli.ts b/src/cli.ts index fdaba5f..6297023 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.2.1" as const; +export const OH_PACKAGE_VERSION = "0.2.2" as const; type ParsedArguments = { options: Map; positionals: string[] }; type ValidatedInvocation = Readonly<{ diff --git a/src/libsql.test.ts b/src/libsql.test.ts index f274db1..bf843fb 100644 --- a/src/libsql.test.ts +++ b/src/libsql.test.ts @@ -8,6 +8,7 @@ import { createOhLibSqlStoreAuthorityV1, OH_LIBSQL_STORE_LIMITS_V1, openExistingOhLibSqlStoreAuthorityV1, + purgeOhLibSqlWorkingSpaceV1, type OhLibSqlClientV1, type OhLibSqlResultV1, type OhLibSqlStatementV1, @@ -177,6 +178,136 @@ describe("direct libSQL Oh authority", () => { provider.close(); }); + test("fences an authority that was abandoned before creation without creating it", async () => { + const provider = await bootstrappedClient(); + const observed: string[] = []; + const purgeOnlyClient: OhLibSqlClientV1 = { + execute: async (statement) => { + const sql = typeof statement === "string" ? statement : statement.sql; + observed.push(sql); + if (/\bINSERT\s+INTO\s+oh_authority_(?:spaces|bindings)\b/iu.test(sql)) { + throw new Error("purge custody cannot create a space or binding"); + } + return await provider.execute(statement); + }, + batch: async (statements, mode) => { + for (const statement of statements) { + observed.push(statement.sql); + if (/\bINSERT\s+INTO\s+oh_authority_(?:spaces|bindings)\b/iu.test(statement.sql)) { + throw new Error("purge custody cannot create a space or binding"); + } + } + return await provider.batch(statements, mode); + }, + }; + const options = { + profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:abandoned-before-create", + spaceId: "abandoned-before-create", + } as const; + const receipt = await purgeOhLibSqlWorkingSpaceV1(purgeOnlyClient, { + ...options, + purgedAt: "2026-08-30T02:30:00.000Z", + }); + expect(receipt.priorHead.sequence).toBe(0); + expect(observed.some((sql) => /\bINSERT\s+INTO\s+oh_authority_purges\b/iu.test(sql))).toBe(true); + expect(observed.some((sql) => /\bINSERT\s+INTO\s+oh_authority_(?:spaces|bindings)\b/iu.test(sql))) + .toBe(false); + expect(await purgeOhLibSqlWorkingSpaceV1(purgeOnlyClient, { + ...options, + purgedAt: "2026-08-30T02:31:00.000Z", + })).toEqual(receipt); + await expect(createOhLibSqlStoreAuthorityV1(provider, options)).rejects.toMatchObject({ + receipt, + }); + provider.close(); + }); + + test("arbitrates a concurrent late creator into a complete purge", async () => { + const provider = await bootstrappedClient(); + const options = { + profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:late-creator", + spaceId: "late-creator", + } as const; + let raced = false; + const racingPurgeClient: OhLibSqlClientV1 = { + execute: async (statement) => await provider.execute(statement), + batch: async (statements, mode) => { + if (!raced && statements.some((statement) => + /\bINSERT\s+INTO\s+oh_authority_purges\b/iu.test(statement.sql))) { + raced = true; + const created = await createOhLibSqlStoreAuthorityV1(provider, options); + await created.store.close(); + } + return await provider.batch(statements, mode); + }, + }; + const receipt = await purgeOhLibSqlWorkingSpaceV1(racingPurgeClient, { + ...options, + purgedAt: "2026-08-30T02:32:00.000Z", + }); + expect(raced).toBe(true); + expect(receipt.priorHead.sequence).toBe(0); + await expect(openExistingOhLibSqlStoreAuthorityV1(provider, options)) + .rejects.toBeInstanceOf(OhPurgedSpaceError); + expect(provider.database.query<{ count: number }, [string]>(`SELECT count(*) AS count + FROM oh_authority_spaces WHERE space_id = ?`).get(options.spaceId)?.count).toBe(0); + provider.close(); + }); + + test("recovers from a failed fence with one atomic concurrent-purge proof", async () => { + const provider = await bootstrappedClient(); + const options = { + profile: OH_WORKING_STORE_PROFILE_V1, + realmId: "realm:concurrent-purge-recovery", + spaceId: "concurrent-purge-recovery", + } as const; + let writeFailed = false; + let concurrentReceipt: Awaited> | null = null; + const racingPurgeClient: OhLibSqlClientV1 = { + execute: async (statement) => await provider.execute(statement), + batch: async (statements, mode) => { + if (mode === "write" && !writeFailed) { + writeFailed = true; + throw new Error("simulated concurrent guard failure"); + } + if (mode === "read" && writeFailed && concurrentReceipt === null + && statements.some((statement) => /FROM oh_authority_bindings WHERE space_id = \?/u + .test(statement.sql))) { + concurrentReceipt = await purgeOhLibSqlWorkingSpaceV1(provider, { + ...options, + purgedAt: "2026-08-30T02:33:00.000Z", + }); + } + return await provider.batch(statements, mode); + }, + }; + const receipt = await purgeOhLibSqlWorkingSpaceV1(racingPurgeClient, options); + expect(writeFailed).toBe(true); + if (concurrentReceipt === null) throw new Error("The concurrent purge did not run."); + expect(receipt).toEqual(concurrentReceipt); + provider.close(); + }); + + test("rolls an absent-space fence back when orphaned payload prevents a complete purge", async () => { + const provider = await bootstrappedClient(); + const spaceId = "corrupt-before-create"; + provider.database.query(`INSERT INTO oh_authority_records(space_id, record_key, kind, + record_sha256, record_json, operation_sha256, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(spaceId, "entity:orphan", "entity", "a".repeat(64), "{}", "b".repeat(64), 1); + await expect(purgeOhLibSqlWorkingSpaceV1(provider, { + profile: OH_WORKING_STORE_PROFILE_V1, + realmId: `realm:${spaceId}`, + spaceId, + })).rejects.toThrow(); + expect(provider.database.query<{ count: number }, [string]>(`SELECT count(*) AS count + FROM oh_authority_purges WHERE space_id = ?`).get(spaceId)?.count).toBe(0); + expect(provider.database.query<{ count: number }, [string]>(`SELECT count(*) AS count + FROM oh_authority_records WHERE space_id = ?`).get(spaceId)?.count).toBe(1); + provider.close(); + }); + test("is an async authoritative store rather than an operation-sync cache", async () => { const client = await bootstrappedClient(); const authority = await createOhLibSqlStoreAuthorityV1(client, { diff --git a/src/libsql.ts b/src/libsql.ts index 4f4eb0a..34bb937 100644 --- a/src/libsql.ts +++ b/src/libsql.ts @@ -18,6 +18,7 @@ import { createOhStoreBindingV1, emptyOhHeadV1, OH_CANONICAL_STORE_PROFILE_V1, + OH_WORKING_STORE_PROFILE_V1, OhConflictError, OhIntegrityError, OhProfileError, @@ -86,6 +87,8 @@ const PURGE_ROW_SELECT = `SELECT space_id, binding_sha256, prior_operation_sha25 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 SPACE_PURGE_PROOF_SELECT = `SELECT generation, graph_revision_sha256, head_operation_sha256, + records_sha256, sequence, contract_id FROM oh_authority_spaces 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 @@ -538,6 +541,44 @@ async function requireExistingSpace( parseHeadRow(spaceRow); } +const PURGE_PAYLOAD_TABLES = ["oh_authority_spaces", "oh_authority_bindings", + "oh_authority_operations", "oh_authority_operation_records", "oh_authority_records", + "oh_authority_dependencies"] as const; + +async function assertRemotePurgeComplete( + client: OhLibSqlClientV1, + binding: OhStoreBindingV1, + expected: OhSpacePurgeReceiptV1, +): Promise { + const results = await client.batch([ + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }, + ...PURGE_PAYLOAD_TABLES.map((table) => ({ + sql: `SELECT count(*) AS count FROM ${table} WHERE space_id = ?`, + args: [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, binding.spaceId, + binding.bindingSha256)) !== canonicalJson(expected)) { + throw new OhIntegrityError("The remote purge receipt differs from the requested purge."); + } + for (let index = 0; index < PURGE_PAYLOAD_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 ${PURGE_PAYLOAD_TABLES[index]}.`); + } + } + const orphanRow = results[PURGE_PAYLOAD_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."); + } +} + class OhLibSqlStoreV1 implements OhStoreV1 { readonly binding: OhStoreBindingV1; readonly #client: OhLibSqlClientV1; @@ -1371,34 +1412,7 @@ class OhLibSqlStoreV1 implements OhStoreV1 { } 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."); - } + await assertRemotePurgeComplete(this.#client, this.binding, expected); } async purgeWorkingSpace(purgedAt: string): Promise { @@ -1550,3 +1564,124 @@ export async function openExistingOhLibSqlStoreAuthorityV1( await requireExistingSpace(client, binding); return bindOhLibSqlStoreAuthorityV1(client, binding, profile, options.closeClient ?? false); } + +/** + * Purges an existing working authority or atomically fences its exact binding + * when creation never completed. The empty-space receipt prevents a delayed + * creator from resurrecting abandoned custody without granting the purge + * credential permission to create a space or binding. + */ +export async function purgeOhLibSqlWorkingSpaceV1( + client: OhLibSqlClientV1, + options: OhLibSqlStoreAuthorityOptionsV1 & Readonly<{ purgedAt?: string }> = {}, +): Promise { + const closeClient = options.closeClient ?? false; + try { + const profile = parseOhStoreProfileV1(options.profile ?? OH_WORKING_STORE_PROFILE_V1); + if (profile === null) throw new TypeError("Invalid libSQL store profile."); + if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) { + throw new OhProfileError("Whole-space purge requires a bound working profile."); + } + const spaceId = options.spaceId ?? "default"; + const binding = createOhStoreBindingV1({ profile, + realmId: options.realmId ?? `realm:${spaceId}`, spaceId, v: 1 }); + const purgedAt = options.purgedAt ?? canonicalNow(); + await verifyAuthoritySchema(client); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const proof = await client.batch([ + { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }, + { sql: SPACE_PURGE_PROOF_SELECT, args: [binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }, + ], "read"); + if (proof.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete purge proof."); + } + const [bindingResult, spaceResult, purgeResult] = proof as + [OhLibSqlResultV1, OhLibSqlResultV1, OhLibSqlResultV1]; + const existingPurge = purgeResult.rows[0]; + if (existingPurge !== undefined) { + const receipt = parsePurgeReceiptRow(existingPurge, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, receipt); + return receipt; + } + const bindingRow = bindingResult.rows[0]; + const spaceRow = spaceResult.rows[0]; + if ((bindingRow === undefined) !== (spaceRow === undefined)) { + throw new OhIntegrityError("The remote authority has only half of its space binding."); + } + if (bindingRow !== undefined && spaceRow !== undefined) { + const persisted = parseBindingRow(bindingRow, binding.spaceId); + if (canonicalJson(persisted) !== canonicalJson(binding)) { + throw new OhProfileError("The remote space is bound to a different realm or profile."); + } + if (rowValue(spaceRow, "contract_id", 5) !== OH_CONTRACT_MANIFEST_V1.contractId) { + throw new OhIntegrityError("The existing remote space uses a different Oh contract."); + } + parseHeadRow(spaceRow); + const authority = new OhLibSqlStoreV1(client, binding, false); + return await authority.purgeWorkingSpace(purgedAt); + } + + const receipt = createOhSpacePurgeReceiptV1({ + binding, + priorHead: emptyOhHeadV1(), + purgedAt, + }); + const receiptJson = canonicalJson(receipt); + try { + await client.batch([ + { sql: `INSERT INTO oh_authority_purges(space_id, binding_sha256, + prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json) + SELECT ?, ?, NULL, 0, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM oh_authority_spaces WHERE space_id = ?) + AND NOT EXISTS (SELECT 1 FROM oh_authority_bindings WHERE space_id = ?) + ON CONFLICT(space_id) DO NOTHING`, + args: [binding.spaceId, binding.bindingSha256, receipt.purgedAt, + receipt.receiptSha256, receiptJson, binding.spaceId, binding.spaceId] }, + { 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_records WHERE space_id = ?) + OR EXISTS (SELECT 1 FROM oh_authority_dependencies 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 NOT EXISTS (SELECT 1 FROM oh_authority_purges + WHERE space_id = ? AND receipt_sha256 = ?)`, + args: [binding.spaceId, binding.spaceId, binding.spaceId, binding.spaceId, + binding.spaceId, binding.spaceId, binding.spaceId, receipt.receiptSha256] }, + ], "write"); + } catch (error) { + const recovery = await client.batch([ + { sql: BINDING_ROW_SELECT, args: [binding.spaceId] }, + { sql: SPACE_PURGE_PROOF_SELECT, args: [binding.spaceId] }, + { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }, + ], "read"); + if (recovery.length !== 3) { + throw new OhIntegrityError("The remote authority returned an incomplete purge recovery proof."); + } + const raced = recovery[2]?.rows[0]; + if (raced !== undefined) { + const persisted = parsePurgeReceiptRow(raced, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, persisted); + return persisted; + } + if (recovery[0]?.rows[0] !== undefined || recovery[1]?.rows[0] !== undefined) continue; + throw error; + } + const persisted = await queryOne(client, { sql: PURGE_ROW_SELECT, args: [binding.spaceId] }); + if (persisted === null) continue; + const exact = parsePurgeReceiptRow(persisted, binding.spaceId, binding.bindingSha256); + await assertRemotePurgeComplete(client, binding, exact); + return exact; + } + throw new OhConflictError("The remote working space changed repeatedly while fencing purge."); + } finally { + if (closeClient) client.close?.(); + } +} diff --git a/tests/node-portable.mjs b/tests/node-portable.mjs index 3330b8e..f8c19f1 100644 --- a/tests/node-portable.mjs +++ b/tests/node-portable.mjs @@ -12,6 +12,7 @@ assert.equal(typeof store.OhRecordCodecRegistry, "function"); assert.equal(typeof store.OhSemanticBundleIngressV1, "function"); assert.equal(typeof libsql.createOhLibSqlStoreAuthorityV1, "function"); assert.equal(typeof libsql.openExistingOhLibSqlStoreAuthorityV1, "function"); +assert.equal(typeof libsql.purgeOhLibSqlWorkingSpaceV1, "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); diff --git a/tests/public-surface.test.ts b/tests/public-surface.test.ts index cad53e9..33da791 100644 --- a/tests/public-surface.test.ts +++ b/tests/public-surface.test.ts @@ -152,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.2.1"); + expect(packageJson.version).toBe("0.2.2"); expect(packageJson.description).toBe(tagline); expect(packageJson.homepage).toBe("https://oh.computer"); expect(packageJson.license).toBe("MIT");