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/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..e100a17f4a --- /dev/null +++ b/tests/control_plane_ts/authority_store_transactions.test.ts @@ -0,0 +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 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); +});