From 0acc8c1b8ae2b995dc67435ace0a994c18393755 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 16:21:48 +0800 Subject: [PATCH 1/2] refactor: centralize shared authority transaction semantics Signed-off-by: huangruiteng --- .../rfcs/shared-authority-semantic-core-v0.md | 53 ++++++++++++++ ...shared-authority-semantic-core-v0.zh-CN.md | 53 ++++++++++++++ .../authority_store_transactions.ts | 39 ++++++++++ .../coordination/file_authority_store.ts | 65 +++-------------- .../coordination/nokv_authority_store.ts | 72 +++---------------- .../authority_store_transactions.test.ts | 10 +++ 6 files changed, 175 insertions(+), 117 deletions(-) create mode 100644 docs/architecture/rfcs/shared-authority-semantic-core-v0.md create mode 100644 docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md create mode 100644 loopx/control_plane/coordination/authority_store_transactions.ts create mode 100644 tests/control_plane_ts/authority_store_transactions.test.ts diff --git a/docs/architecture/rfcs/shared-authority-semantic-core-v0.md b/docs/architecture/rfcs/shared-authority-semantic-core-v0.md new file mode 100644 index 0000000000..cf355030ce --- /dev/null +++ b/docs/architecture/rfcs/shared-authority-semantic-core-v0.md @@ -0,0 +1,53 @@ +# RFC: Shared Authority Semantic Core v0 + +- Status: Proposed +- Scope: the TypeScript control-plane migration and shared-authority provider RFCs +- Date: 2026-09-12 + +## Summary + +The two current RFCs establish a provider-neutral authority contract, but provider +adapters still contain repeated wire decoding and transaction-shaping rules. This +RFC makes the semantic core executable: one strict transaction codec is shared by +file and NoKV today and is the required seam for SQLite and PostgreSQL adapters. +Providers retain storage-specific revision and failure behavior; they no longer +own copies of the canonical transaction shape. + +## Semantic changes + +1. A committed transaction has one canonical decoder with exact keys and strict + JSON object/list validation. Unknown keys, malformed lists, and non-string + identity fields fail closed identically across providers. +2. Revision input is derived from one shared `transactionForRevision` projection, + preventing a provider from accidentally including its own storage metadata in + the logical transaction payload. Provider-specific envelopes remain in the + provider revision hash. +3. Cloning is explicit at the shared boundary, so scan/read callers cannot mutate + provider-owned history through an adapter-specific copy convention. +4. The refactor is behavior preserving for valid records and intentionally tightens + malformed-record parity. No claim, lease, archive, or promotion authority is + granted by the codec. + +## Delivery plan + +Stage A extracts the shared TypeScript module and adds a complex fixture covering +valid native records, legacy-compatible records, reordered keys, unknown keys, +malformed receipts, and revision projection. Stage B migrates SQLite and PostgreSQL +call sites and removes their local shape helpers. Stage C qualifies the complete +provider matrix and the ten-day local persistence target from the shared-authority +RFC. + +## Validation and real state + +The focused TypeScript suite and full control-plane suite must pass. A read-only +`loopx --format json status --goal-id loopx-meta` run is required before delivery; +its warnings are evidence about the live goal and are not rewritten by this RFC. + +## Duplication removed + +Before this stage, `file_authority_store.ts` and `nokv_authority_store.ts` each +implemented the same exact-key check, identity validation, event/projection/receipt +canonicalization, transaction clone, and revision-input projection. The copies +were vulnerable to drift: one provider could accept a payload the other rejected. +The new module owns those shared semantics; only storage envelope decoding and +provider-specific revision salts remain local. diff --git a/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md b/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md new file mode 100644 index 0000000000..bc2010dd7c --- /dev/null +++ b/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md @@ -0,0 +1,53 @@ +# RFC:Shared Authority Semantic Core v0(共享权威语义核心) + +- 状态:提案 +- 范围:TypeScript 控制平面迁移与 shared-authority provider 两个 RFC 的交汇边界 +- 日期:2026-09-12 + +## 摘要 + +The two current RFCs establish a provider-neutral authority contract, but provider +adapters still contain repeated wire decoding and transaction-shaping rules. This +RFC makes the semantic core executable: one strict transaction codec is shared by +file and NoKV today and is the required seam for SQLite and PostgreSQL adapters. +Providers retain storage-specific revision and failure behavior; they no longer +own copies of the canonical transaction shape. + +## 语义变化 + +1. A committed transaction has one canonical decoder with exact keys and strict + JSON object/list validation. Unknown keys, malformed lists, and non-string + identity fields fail closed identically across providers. +2. Revision input is derived from one shared `transactionForRevision` projection, + preventing a provider from accidentally including its own storage metadata in + the logical transaction payload. Provider-specific envelopes remain in the + provider revision hash. +3. Cloning is explicit at the shared boundary, so scan/read callers cannot mutate + provider-owned history through an adapter-specific copy convention. +4. The refactor is behavior preserving for valid records and intentionally tightens + malformed-record parity. No claim, lease, archive, or promotion authority is + granted by the codec. + +## 交付计划 + +Stage A extracts the shared TypeScript module and adds a complex fixture covering +valid native records, legacy-compatible records, reordered keys, unknown keys, +malformed receipts, and revision projection. Stage B migrates SQLite and PostgreSQL +call sites and removes their local shape helpers. Stage C qualifies the complete +provider matrix and the ten-day local persistence target from the shared-authority +RFC. + +## 验证与真实状态 + +The focused TypeScript suite and full control-plane suite must pass. A read-only +`loopx --format json status --goal-id loopx-meta` run is required before delivery; +its warnings are evidence about the live goal and are not rewritten by this RFC. + +## 消除的重复 + +Before this stage, `file_authority_store.ts` and `nokv_authority_store.ts` each +implemented the same exact-key check, identity validation, event/projection/receipt +canonicalization, transaction clone, and revision-input projection. The copies +were vulnerable to drift: one provider could accept a payload the other rejected. +The new module owns those shared semantics; only storage envelope decoding and +provider-specific revision salts remain local. diff --git a/loopx/control_plane/coordination/authority_store_transactions.ts b/loopx/control_plane/coordination/authority_store_transactions.ts new file mode 100644 index 0000000000..28ab4dab7d --- /dev/null +++ b/loopx/control_plane/coordination/authority_store_transactions.ts @@ -0,0 +1,39 @@ +import type { JsonObject } from "../effect_program.ts"; +import type { AuthorityStoreCommittedTransaction } from "./authority_store.ts"; +import { + AuthorityStoreProtocolError, + canonicalAuthorityObject, + canonicalAuthorityObjectList, + hasExactAuthorityKeys, + isAuthorityJsonObject, + requireAuthorityStoreId, +} from "./authority_store_codec.ts"; + +/** Shared wire decoder used by every authority provider. */ +export function decodeAuthorityTransaction(value: unknown): AuthorityStoreCommittedTransaction { + if (!isAuthorityJsonObject(value) || !hasExactAuthorityKeys(value, [ + "cursor", "provider_revision", "operation_id", "events", "projection", "receipts", + ])) throw new AuthorityStoreProtocolError("committed transaction is invalid"); + return { + cursor: requireAuthorityStoreId(value.cursor, "transaction cursor"), + provider_revision: requireAuthorityStoreId(value.provider_revision, "transaction provider revision"), + operation_id: requireAuthorityStoreId(value.operation_id, "operation id"), + events: canonicalAuthorityObjectList(value.events, "transaction events"), + projection: canonicalAuthorityObject(value.projection, "transaction projection"), + receipts: canonicalAuthorityObjectList(value.receipts, "transaction receipts"), + }; +} + +export function cloneAuthorityTransaction(value: AuthorityStoreCommittedTransaction): AuthorityStoreCommittedTransaction { + return structuredClone(value); +} + +export function transactionForRevision(value: AuthorityStoreCommittedTransaction): JsonObject { + return { + cursor: value.cursor, + operation_id: value.operation_id, + events: value.events, + projection: value.projection, + receipts: value.receipts, + }; +} diff --git a/loopx/control_plane/coordination/file_authority_store.ts b/loopx/control_plane/coordination/file_authority_store.ts index 99ee9cb7ea..b89fe6b297 100644 --- a/loopx/control_plane/coordination/file_authority_store.ts +++ b/loopx/control_plane/coordination/file_authority_store.ts @@ -18,16 +18,17 @@ import type { } from "./authority_store.ts"; import { AuthorityStoreProtocolError, + isAuthorityJsonObject, + hasExactAuthorityKeys, + canonicalAuthorityObjectList, + canonicalAuthorityObject, authorityUnicodeCompare, canonicalAuthorityBytes, - canonicalAuthorityObject, - canonicalAuthorityObjectList, - hasExactAuthorityKeys, - isAuthorityJsonObject, normalizeAuthorityStoreCommit, parseAuthorityCursor, requireAuthorityStoreId, } from "./authority_store_codec.ts"; +import { cloneAuthorityTransaction, decodeAuthorityTransaction, transactionForRevision } from "./authority_store_transactions.ts"; const FILE_AUTHORITY_STORE_SCHEMA = "loopx_file_authority_store_v0"; const STORE_IDENTITY_PATTERN = /^file:[0-9a-f]{32}$/; @@ -77,37 +78,8 @@ export type FileAuthorityArchiveResult = reason: string; }; -function cloneTransaction( - value: AuthorityStoreCommittedTransaction, -): AuthorityStoreCommittedTransaction { - return structuredClone(value); -} - -function transactionWithoutRevision(value: AuthorityStoreCommittedTransaction) { - return { - cursor: value.cursor, - operation_id: value.operation_id, - events: value.events, - projection: value.projection, - receipts: value.receipts, - }; -} - -function providerRevision( - goalId: string, - storeIdentity: string, - previousRevision: string | null, - transaction: ReturnType, -): string { - const digest = createHash("sha256") - .update(canonicalAuthorityBytes({ - goal_id: goalId, - store_identity: storeIdentity, - previous_provider_revision: previousRevision, - transaction, - })) - .digest("hex") - .slice(0, 24); +function providerRevision(goalId: string, storeIdentity: string, previousRevision: string | null, transaction: ReturnType): string { + const digest = createHash("sha256").update(canonicalAuthorityBytes({ goal_id: goalId, store_identity: storeIdentity, previous_provider_revision: previousRevision, transaction })).digest("hex").slice(0, 24); return `file:${transaction.cursor}:${digest}`; } @@ -140,23 +112,6 @@ async function durableReplace(path: string, payload: Uint8Array): Promise } } -function decodeTransaction(value: unknown): AuthorityStoreCommittedTransaction { - if (!isAuthorityJsonObject(value) || !hasExactAuthorityKeys(value, [ - "cursor", "provider_revision", "operation_id", "events", "projection", "receipts", - ])) throw new AuthorityStoreProtocolError("committed transaction is invalid"); - return { - cursor: requireAuthorityStoreId(value.cursor, "transaction cursor"), - provider_revision: requireAuthorityStoreId( - value.provider_revision, - "transaction provider revision", - ), - operation_id: requireAuthorityStoreId(value.operation_id, "operation id"), - events: canonicalAuthorityObjectList(value.events, "transaction events"), - projection: canonicalAuthorityObject(value.projection, "transaction projection"), - receipts: canonicalAuthorityObjectList(value.receipts, "transaction receipts"), - }; -} - function decodeDocument( value: unknown, goalId: string, @@ -178,7 +133,7 @@ function decodeDocument( if (!Array.isArray(value.committed)) { throw new AuthorityStoreProtocolError("file authority store history is invalid"); } - const committed = value.committed.map(decodeTransaction); + const committed = value.committed.map(decodeAuthorityTransaction); if (committed.length === 0 || parseAuthorityCursor(cursor) !== BigInt(committed.length)) { throw new AuthorityStoreProtocolError("file authority store lineage is invalid"); } @@ -196,7 +151,7 @@ function decodeDocument( goalId, storeIdentity, previousRevision, - transactionWithoutRevision(entry), + transactionForRevision(entry), ); if (entry.provider_revision !== expectedRevision) { throw new AuthorityStoreProtocolError("file authority store revision lineage is invalid"); @@ -495,7 +450,7 @@ export class FileAuthorityStore implements AuthorityStore { }; } const start = Number(offset); - const transactions = document.committed.slice(start, start + limit).map(cloneTransaction); + const transactions = document.committed.slice(start, start + limit).map(cloneAuthorityTransaction); return { status: "page", transactions, diff --git a/loopx/control_plane/coordination/nokv_authority_store.ts b/loopx/control_plane/coordination/nokv_authority_store.ts index 5775680caa..c655e3389f 100644 --- a/loopx/control_plane/coordination/nokv_authority_store.ts +++ b/loopx/control_plane/coordination/nokv_authority_store.ts @@ -15,15 +15,16 @@ import type { } from "./authority_store.ts"; import { AuthorityStoreProtocolError, - canonicalAuthorityBytes, - canonicalAuthorityObject, - canonicalAuthorityObjectList, - hasExactAuthorityKeys, isAuthorityJsonObject, + hasExactAuthorityKeys, + canonicalAuthorityObjectList, + canonicalAuthorityObject, + canonicalAuthorityBytes, normalizeAuthorityStoreCommit, parseAuthorityCursor, requireAuthorityStoreId, } from "./authority_store_codec.ts"; +import { cloneAuthorityTransaction, decodeAuthorityTransaction, transactionForRevision } from "./authority_store_transactions.ts"; const NOKV_AUTHORITY_STORE_SCHEMA = "loopx_nokv_authority_store_v0"; const DEFAULT_MAX_ENVELOPE_BYTES = 16 * 1024 * 1024; @@ -98,42 +99,8 @@ type EnvelopeReadResult = | { status: "missing"; identity: string } | AuthorityStoreReadFailure; -function cloneTransaction( - value: AuthorityStoreCommittedTransaction, -): AuthorityStoreCommittedTransaction { - return structuredClone(value); -} - -function transactionWithoutRevision(value: AuthorityStoreCommittedTransaction) { - return { - cursor: value.cursor, - operation_id: value.operation_id, - events: value.events, - projection: value.projection, - receipts: value.receipts, - }; -} - -function providerRevision( - tenantId: string, - goalId: string, - storeIdentity: string, - storageGeneration: number, - previousRevision: string | null, - transaction: ReturnType, -): string { - const digest = createHash("sha256") - .update(canonicalAuthorityBytes({ - provider: "nokv", - tenant_id: tenantId, - goal_id: goalId, - store_identity: storeIdentity, - storage_generation: storageGeneration, - previous_provider_revision: previousRevision, - transaction, - })) - .digest("hex") - .slice(0, 24); +function providerRevision(tenantId: string, goalId: string, storeIdentity: string, storageGeneration: number, previousRevision: string | null, transaction: ReturnType): string { + const digest = createHash("sha256").update(canonicalAuthorityBytes({ provider: "nokv", tenant_id: tenantId, goal_id: goalId, store_identity: storeIdentity, storage_generation: storageGeneration, previous_provider_revision: previousRevision, transaction })).digest("hex").slice(0, 24); return `nokv:${transaction.cursor}:${digest}`; } @@ -164,25 +131,6 @@ function requireGeneration(value: unknown, name: string): number { return value as number; } -function decodeTransaction(value: unknown): AuthorityStoreCommittedTransaction { - if (!isAuthorityJsonObject(value) || !hasExactAuthorityKeys(value, [ - "cursor", "provider_revision", "operation_id", "events", "projection", "receipts", - ])) { - throw new AuthorityStoreProtocolError("committed transaction is invalid"); - } - return { - cursor: requireAuthorityStoreId(value.cursor, "transaction cursor"), - provider_revision: requireAuthorityStoreId( - value.provider_revision, - "transaction provider revision", - ), - operation_id: requireAuthorityStoreId(value.operation_id, "operation id"), - events: canonicalAuthorityObjectList(value.events, "transaction events"), - projection: canonicalAuthorityObject(value.projection, "transaction projection"), - receipts: canonicalAuthorityObjectList(value.receipts, "transaction receipts"), - }; -} - function decodeDocument( value: unknown, tenantId: string, @@ -220,7 +168,7 @@ function decodeDocument( if (!Array.isArray(value.committed)) { throw new AuthorityStoreProtocolError("NoKV authority store history is invalid"); } - const committed = value.committed.map(decodeTransaction); + const committed = value.committed.map(decodeAuthorityTransaction); if ( committed.length === 0 || storageGeneration !== committed.length || @@ -247,7 +195,7 @@ function decodeDocument( storeIdentity, generation, previousRevision, - transactionWithoutRevision(entry), + transactionForRevision(entry), ); if (entry.provider_revision !== expectedRevision) { throw new AuthorityStoreProtocolError("NoKV authority store revision lineage is invalid"); @@ -660,7 +608,7 @@ export class NoKVAuthorityStore implements AuthorityStore { const start = Number(offset); const transactions = result.document.committed .slice(start, start + limit) - .map(cloneTransaction); + .map(cloneAuthorityTransaction); return { status: "page", transactions, diff --git a/tests/control_plane_ts/authority_store_transactions.test.ts b/tests/control_plane_ts/authority_store_transactions.test.ts new file mode 100644 index 0000000000..199aae314c --- /dev/null +++ b/tests/control_plane_ts/authority_store_transactions.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeAuthorityTransaction, transactionForRevision } from "../../loopx/control_plane/coordination/authority_store_transactions.ts"; + +test("all providers share one strict transaction decoder", () => { + const tx = decodeAuthorityTransaction({cursor:"1", provider_revision:"file:1:x", operation_id:"op", events:[], projection:{}, receipts:[]}); + assert.deepEqual(transactionForRevision(tx), {cursor:"1", operation_id:"op", events:[], projection:{}, receipts:[]}); + assert.throws(() => decodeAuthorityTransaction({...tx, receipts: "not-an-array"}), /transaction receipts/); + assert.throws(() => decodeAuthorityTransaction({...tx, extra: true}), /committed transaction is invalid/); +}); From a34fa8edd198632de2e3c0d73a9726c27994d058 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 17:01:00 +0800 Subject: [PATCH 2/2] test: qualify shared authority transaction parity Signed-off-by: huangruiteng --- .../rfcs/shared-authority-semantic-core-v0.md | 53 ---- ...shared-authority-semantic-core-v0.zh-CN.md | 53 ---- ...shared-goal-authority-state-provider-v0.md | 22 ++ ...-goal-authority-state-provider-v0.zh-CN.md | 19 ++ .../authority_store_transactions.test.ts | 298 +++++++++++++++++- 5 files changed, 333 insertions(+), 112 deletions(-) delete mode 100644 docs/architecture/rfcs/shared-authority-semantic-core-v0.md delete mode 100644 docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md diff --git a/docs/architecture/rfcs/shared-authority-semantic-core-v0.md b/docs/architecture/rfcs/shared-authority-semantic-core-v0.md deleted file mode 100644 index cf355030ce..0000000000 --- a/docs/architecture/rfcs/shared-authority-semantic-core-v0.md +++ /dev/null @@ -1,53 +0,0 @@ -# RFC: Shared Authority Semantic Core v0 - -- Status: Proposed -- Scope: the TypeScript control-plane migration and shared-authority provider RFCs -- Date: 2026-09-12 - -## Summary - -The two current RFCs establish a provider-neutral authority contract, but provider -adapters still contain repeated wire decoding and transaction-shaping rules. This -RFC makes the semantic core executable: one strict transaction codec is shared by -file and NoKV today and is the required seam for SQLite and PostgreSQL adapters. -Providers retain storage-specific revision and failure behavior; they no longer -own copies of the canonical transaction shape. - -## Semantic changes - -1. A committed transaction has one canonical decoder with exact keys and strict - JSON object/list validation. Unknown keys, malformed lists, and non-string - identity fields fail closed identically across providers. -2. Revision input is derived from one shared `transactionForRevision` projection, - preventing a provider from accidentally including its own storage metadata in - the logical transaction payload. Provider-specific envelopes remain in the - provider revision hash. -3. Cloning is explicit at the shared boundary, so scan/read callers cannot mutate - provider-owned history through an adapter-specific copy convention. -4. The refactor is behavior preserving for valid records and intentionally tightens - malformed-record parity. No claim, lease, archive, or promotion authority is - granted by the codec. - -## Delivery plan - -Stage A extracts the shared TypeScript module and adds a complex fixture covering -valid native records, legacy-compatible records, reordered keys, unknown keys, -malformed receipts, and revision projection. Stage B migrates SQLite and PostgreSQL -call sites and removes their local shape helpers. Stage C qualifies the complete -provider matrix and the ten-day local persistence target from the shared-authority -RFC. - -## Validation and real state - -The focused TypeScript suite and full control-plane suite must pass. A read-only -`loopx --format json status --goal-id loopx-meta` run is required before delivery; -its warnings are evidence about the live goal and are not rewritten by this RFC. - -## Duplication removed - -Before this stage, `file_authority_store.ts` and `nokv_authority_store.ts` each -implemented the same exact-key check, identity validation, event/projection/receipt -canonicalization, transaction clone, and revision-input projection. The copies -were vulnerable to drift: one provider could accept a payload the other rejected. -The new module owns those shared semantics; only storage envelope decoding and -provider-specific revision salts remain local. diff --git a/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md b/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md deleted file mode 100644 index bc2010dd7c..0000000000 --- a/docs/architecture/rfcs/shared-authority-semantic-core-v0.zh-CN.md +++ /dev/null @@ -1,53 +0,0 @@ -# RFC:Shared Authority Semantic Core v0(共享权威语义核心) - -- 状态:提案 -- 范围:TypeScript 控制平面迁移与 shared-authority provider 两个 RFC 的交汇边界 -- 日期:2026-09-12 - -## 摘要 - -The two current RFCs establish a provider-neutral authority contract, but provider -adapters still contain repeated wire decoding and transaction-shaping rules. This -RFC makes the semantic core executable: one strict transaction codec is shared by -file and NoKV today and is the required seam for SQLite and PostgreSQL adapters. -Providers retain storage-specific revision and failure behavior; they no longer -own copies of the canonical transaction shape. - -## 语义变化 - -1. A committed transaction has one canonical decoder with exact keys and strict - JSON object/list validation. Unknown keys, malformed lists, and non-string - identity fields fail closed identically across providers. -2. Revision input is derived from one shared `transactionForRevision` projection, - preventing a provider from accidentally including its own storage metadata in - the logical transaction payload. Provider-specific envelopes remain in the - provider revision hash. -3. Cloning is explicit at the shared boundary, so scan/read callers cannot mutate - provider-owned history through an adapter-specific copy convention. -4. The refactor is behavior preserving for valid records and intentionally tightens - malformed-record parity. No claim, lease, archive, or promotion authority is - granted by the codec. - -## 交付计划 - -Stage A extracts the shared TypeScript module and adds a complex fixture covering -valid native records, legacy-compatible records, reordered keys, unknown keys, -malformed receipts, and revision projection. Stage B migrates SQLite and PostgreSQL -call sites and removes their local shape helpers. Stage C qualifies the complete -provider matrix and the ten-day local persistence target from the shared-authority -RFC. - -## 验证与真实状态 - -The focused TypeScript suite and full control-plane suite must pass. A read-only -`loopx --format json status --goal-id loopx-meta` run is required before delivery; -its warnings are evidence about the live goal and are not rewritten by this RFC. - -## 消除的重复 - -Before this stage, `file_authority_store.ts` and `nokv_authority_store.ts` each -implemented the same exact-key check, identity validation, event/projection/receipt -canonicalization, transaction clone, and revision-input projection. The copies -were vulnerable to drift: one provider could accept a payload the other rejected. -The new module owns those shared semantics; only storage envelope decoding and -provider-specific revision salts remain local. diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index d01346ef5a..650395631d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1362,9 +1362,31 @@ relevant contract, exact implementation boundary, and validation evidence. | Stage 2B PostgreSQL candidate | PostgreSQL store/RLS conformance, without runtime promotion | | Stage 2C runtime shadow | Parity, read-candidate, bootstrap, rollback, cutover kernel, and writer fence | | Stage 2 slice | Reference aggregate/provider implementation and initial NoKV evidence | +| Stage 1 semantic transaction core (#4280) | Shared strict transaction decode, clone isolation, revision projection, and file/NoKV parity fixture | | Stage 3 slice | Recoverable lifecycle, retention findings, and live provider limits | | Stage-ladder evidence | Executable stage claims, environment gates, and pending rows | +#### Stage 1 semantic transaction core (#4280) + +The file and NoKV adapters now consume one executable semantic core at +`loopx/control_plane/coordination/authority_store_transactions.ts`. It owns the +exact committed-transaction key set, strict JSON/object-list validation, +canonicalization, explicit structured cloning, and the logical +`transactionForRevision` projection. Provider envelopes, storage generations, +failure mapping, and provider-specific revision salts remain in their owning +adapters. This removes duplicated semantic knowledge without creating another +authority writer or changing the default authority source. SQLite and +PostgreSQL row/envelope migration remain later provider stages. + +The public fixture in +`tests/control_plane_ts/authority_store_transactions.test.ts` runs native, +reordered legacy-compatible, unknown-key, malformed-list, malformed-nested, +and non-string-identity records through the shared decoder and both active file +and NoKV read paths. It also proves that scan results are isolated clones and +that provider metadata is absent from the logical revision projection. This is +Stage 1 parity evidence, not provider promotion or a claim that all later +provider profiles are qualified. + #### Stage 2C observation foundation: local post-commit capture The first half of Stage 2C is an explicit, default-off product path. Preview diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 64ed57da3f..942124c3f5 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -1084,9 +1084,28 @@ Stage 3/4 qualification 必须保持以下 ownership 与 proof 边界: | Stage 2B PostgreSQL candidate | PostgreSQL store/RLS conformance,不代表 runtime promotion | | Stage 2C runtime shadow | parity、read-candidate、bootstrap、rollback、cutover kernel 与 writer fence | | Stage 2 slice | reference aggregate/provider 实现与初步 NoKV 证据 | +| Stage 1 semantic transaction core (#4280) | 共享严格 transaction 解码、clone 隔离、revision 投影,以及 file/NoKV parity fixture | | Stage 3 slice | 可恢复 lifecycle、retention 结论与 live provider 限制 | | Stage-ladder evidence | 可执行 stage claim、环境 gate 与 pending row | +#### Stage 1 semantic transaction core(#4280):共享 transaction 语义核心 + +file 与 NoKV adapter 现在共同使用 +`loopx/control_plane/coordination/authority_store_transactions.ts`。该模块负责 +committed transaction 的精确顶层 key 集合、严格 JSON/object-list 校验、canonicalization、 +显式 structured clone,以及逻辑 `transactionForRevision` 投影。provider envelope、 +storage generation、failure mapping 与 provider-specific revision salt 仍由各自 adapter +负责。这样消除了重复的语义知识,但没有增加新的 authority writer,也没有改变默认的 +authority source。SQLite 与 PostgreSQL 的 row/envelope 迁移仍属于后续 provider stage。 + +公开 fixture 位于 +`tests/control_plane_ts/authority_store_transactions.test.ts`,会把 native、reordered +legacy-compatible、unknown-key、malformed-list、malformed-nested 与 non-string identity +记录同时送入 shared decoder 以及当前两个 active provider 的 file/NoKV read path。它还 +验证 scan 结果是隔离 clone,并验证 provider metadata 不会进入 logical revision projection。 +这些是 Stage 1 parity 证据,不代表 provider promotion,也不代表后续 provider profile +已经完成资格化。 + #### Stage 2C 观察基础:本地提交后 capture Stage 2C 的前半段是一个显式开启、默认关闭的产品路径。先预览,再开启: diff --git a/tests/control_plane_ts/authority_store_transactions.test.ts b/tests/control_plane_ts/authority_store_transactions.test.ts index 199aae314c..e100a17f4a 100644 --- a/tests/control_plane_ts/authority_store_transactions.test.ts +++ b/tests/control_plane_ts/authority_store_transactions.test.ts @@ -1,10 +1,296 @@ import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; -import { decodeAuthorityTransaction, transactionForRevision } from "../../loopx/control_plane/coordination/authority_store_transactions.ts"; -test("all providers share one strict transaction decoder", () => { - const tx = decodeAuthorityTransaction({cursor:"1", provider_revision:"file:1:x", operation_id:"op", events:[], projection:{}, receipts:[]}); - assert.deepEqual(transactionForRevision(tx), {cursor:"1", operation_id:"op", events:[], projection:{}, receipts:[]}); - assert.throws(() => decodeAuthorityTransaction({...tx, receipts: "not-an-array"}), /transaction receipts/); - assert.throws(() => decodeAuthorityTransaction({...tx, extra: true}), /committed transaction is invalid/); +import type { + AuthorityStore, + AuthorityStoreCommit, +} from "../../loopx/control_plane/coordination/authority_store.ts"; +import { + FileAuthorityStore, +} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import { + NoKVAuthorityStore, + type NoKVBlobCasRequest, + type NoKVBlobCasResult, + type NoKVBlobReadResult, + type NoKVBlobTransport, + type NoKVStoreIdentityResult, +} from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; +import { + cloneAuthorityTransaction, + decodeAuthorityTransaction, + transactionForRevision, +} from "../../loopx/control_plane/coordination/authority_store_transactions.ts"; + +type MutableRecord = Record; + +interface TransactionFixture { + name: string; + expected: "accept" | "reject"; + mutate(transaction: MutableRecord): MutableRecord; +} + +const seedCommit: AuthorityStoreCommit = { + expected_provider_revision: null, + operation_id: "semantic-fixture", + events: [{ + type: "fixture_event", + metadata: { z: "last", a: ["nested", { stable: true }] }, + }], + next_projection: { + schema_version: "fixture_projection_v0", + nested: { z: 2, a: 1 }, + }, + receipts: [{ + operation_id: "semantic-fixture", + accepted: true, + details: { z: "last", a: "first" }, + }], +}; + +function copyRecord(value: MutableRecord): MutableRecord { + return { ...value }; +} + +function reverseObjectKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(reverseObjectKeys); + if (value === null || typeof value !== "object") return value; + const entries = Object.entries(value as MutableRecord).reverse().map(([key, item]) => [ + key, + reverseObjectKeys(item), + ] as const); + return Object.fromEntries(entries); +} + +const transactionFixtures: readonly TransactionFixture[] = [ + { + name: "native record", + expected: "accept", + mutate: copyRecord, + }, + { + name: "legacy-compatible record with reordered nested keys", + expected: "accept", + mutate: (transaction) => reverseObjectKeys(transaction) as MutableRecord, + }, + { + name: "unknown top-level key", + expected: "reject", + mutate: (transaction) => ({ ...transaction, stale_metadata: true }), + }, + { + name: "malformed receipts list", + expected: "reject", + mutate: (transaction) => ({ ...transaction, receipts: "not-an-array" }), + }, + { + name: "malformed nested event record", + expected: "reject", + mutate: (transaction) => ({ ...transaction, events: [null] }), + }, + { + name: "non-string operation identity", + expected: "reject", + mutate: (transaction) => ({ ...transaction, operation_id: 7 }), + }, +]; + +function logicalFixtureTransaction(): MutableRecord { + return { + cursor: "1", + provider_revision: "file:1:fixture", + operation_id: seedCommit.operation_id, + events: seedCommit.events, + projection: seedCommit.next_projection, + receipts: seedCommit.receipts, + }; +} + +function expectedRevisionInput(): MutableRecord { + return { + cursor: "1", + operation_id: seedCommit.operation_id, + events: seedCommit.events, + projection: seedCommit.next_projection, + receipts: seedCommit.receipts, + }; +} + +interface FixtureProvider { + name: string; + store: AuthorityStore; + readDocument(): Promise; + writeDocument(document: MutableRecord): Promise; + cleanup(): Promise; +} + +interface FixtureNoKVBackend { + identity: string; + blob: { bytes: Uint8Array; generation: number } | null; +} + +class FixtureNoKVTransport implements NoKVBlobTransport { + readonly backend: FixtureNoKVBackend; + + constructor(backend: FixtureNoKVBackend) { + this.backend = backend; + } + + async storeIdentity(_workbench: string): Promise { + return { status: "available", store_identity: this.backend.identity }; + } + + async readBlob(_workbench: string, _path: string): Promise { + return this.backend.blob + ? { + status: "loaded", + bytes: this.backend.blob.bytes.slice(), + generation: this.backend.blob.generation, + } + : { status: "missing" }; + } + + async casPublishBlob(request: NoKVBlobCasRequest): Promise { + const currentGeneration = this.backend.blob?.generation ?? null; + if (currentGeneration !== request.expected_generation) { + return { status: "conflict", current_generation: currentGeneration }; + } + const generation = (currentGeneration ?? 0) + 1; + this.backend.blob = { bytes: request.bytes.slice(), generation }; + return { status: "applied", generation }; + } +} + +async function seed(store: AuthorityStore): Promise { + const result = await store.commitAuthority(seedCommit); + assert.equal(result.status, "applied", JSON.stringify(result)); +} + +async function createFileProvider(): Promise { + const root = await mkdtemp(join(tmpdir(), "authority-semantic-file-")); + const store = new FileAuthorityStore(root, "goal-a"); + await seed(store); + return { + name: "file", + store, + async readDocument() { + return JSON.parse(await readFile(store.path, "utf8")) as MutableRecord; + }, + async writeDocument(document) { + await writeFile(store.path, JSON.stringify(document)); + }, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +async function createNoKVProvider(): Promise { + const backend: FixtureNoKVBackend = { + identity: `nokv:authority-workbench:${"a".repeat(32)}`, + blob: null, + }; + const store = new NoKVAuthorityStore(new FixtureNoKVTransport(backend), { + tenant_id: "tenant-a", + goal_id: "goal-a", + workbench: "authority-workbench", + }); + await seed(store); + return { + name: "NoKV", + store, + async readDocument() { + assert.ok(backend.blob); + return JSON.parse(new TextDecoder().decode(backend.blob.bytes)) as MutableRecord; + }, + async writeDocument(document) { + assert.ok(backend.blob); + backend.blob = { + generation: backend.blob.generation, + bytes: new TextEncoder().encode(JSON.stringify(document)), + }; + }, + async cleanup() {}, + }; +} + +const providerFactories: readonly [string, () => Promise][] = [ + ["file", createFileProvider], + ["NoKV", createNoKVProvider], +]; + +test("shared decoder enforces the complex transaction fixture", () => { + for (const fixture of transactionFixtures) { + const candidate = fixture.mutate(logicalFixtureTransaction()); + if (fixture.expected === "accept") { + const decoded = decodeAuthorityTransaction(candidate); + assert.deepEqual(transactionForRevision(decoded), expectedRevisionInput(), fixture.name); + assert.equal(decoded.provider_revision, "file:1:fixture", fixture.name); + } else { + assert.throws(() => decodeAuthorityTransaction(candidate), fixture.name); + } + } +}); + +test("file and NoKV providers share fixture acceptance and revision projection", async (t) => { + for (const fixture of transactionFixtures) { + await t.test(fixture.name, async () => { + for (const [, createProvider] of providerFactories) { + const provider = await createProvider(); + try { + const baseline = await provider.readDocument(); + const baselineTransaction = baseline.committed as MutableRecord[]; + baselineTransaction[0] = fixture.mutate(baselineTransaction[0]!); + await provider.writeDocument(baseline); + const loaded = await provider.store.loadAuthority(); + if (fixture.expected === "accept") { + assert.equal(loaded.status, "loaded", `${provider.name}: ${JSON.stringify(loaded)}`); + if (loaded.status === "loaded") { + assert.equal(loaded.cursor, "1"); + assert.equal(loaded.provider_revision, baseline.provider_revision as string); + } + } else { + assert.equal(loaded.status, "failed", `${provider.name}: ${JSON.stringify(loaded)}`); + if (loaded.status === "failed") { + assert.equal(loaded.reason_code, "provider_protocol_violation"); + } + } + } finally { + await provider.cleanup(); + } + } + }); + } +}); + +test("file and NoKV scan results are isolated clones", async (t) => { + for (const [name, createProvider] of providerFactories) { + await t.test(name, async () => { + const provider = await createProvider(); + try { + const first = await provider.store.scanCommitted(null, 10); + assert.equal(first.status, "page"); + if (first.status !== "page") return; + const firstTransaction = first.transactions[0]!; + (firstTransaction.projection as MutableRecord).mutated = true; + (firstTransaction.events as MutableRecord[]).push({ leaked: true }); + const second = await provider.store.scanCommitted(null, 10); + assert.equal(second.status, "page"); + if (second.status !== "page") return; + assert.equal((second.transactions[0]!.projection as MutableRecord).mutated, undefined); + assert.equal(second.transactions[0]!.events.length, 1); + } finally { + await provider.cleanup(); + } + }); + } +}); + +test("transaction clone preserves the canonical logical projection", () => { + const decoded = decodeAuthorityTransaction(logicalFixtureTransaction()); + const cloned = cloneAuthorityTransaction(decoded); + assert.deepEqual(transactionForRevision(cloned), expectedRevisionInput()); + (cloned.projection as MutableRecord).changed = true; + assert.equal((decoded.projection as MutableRecord).changed, undefined); });