From 4d1bf26eaa2b6000458cb865aa0aa88244f94108 Mon Sep 17 00:00:00 2001 From: NB <208086304+chitcommit@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:43:56 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(coa):=20the=20=C2=A77=20gate=20package?= =?UTF-8?q?=20for=20Change=20A=20(structural)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change A is the structural half of the chart-of-accounts migration: the 25 accounts the authoritative document defines and the live global chart does not hold, the parent_code hierarchy, the one renamed account, and the description drift. Change B — the five schedule_e_line drifts — is the tax-semantic class and ships separately per §6. The package emits the compiled statements (25 INSERT in two ordered phases, then 34 UPDATE), the §4 pre-write snapshot, the §7 rollback recipe, the §5 reconciliation digest, the blast radius and the §10 gaps. Nothing was applied. The apply remains operator-gated. Supersedes #214's package, which compiled four UPDATEs over 5015/5025/5055/6050 asserting a tax_deductible defect. Those codes hold no row — they are INSERTs here. The UPDATEs would have matched zero rows and reported success. The bug was the FIXTURE, not computeSeedPlan: #214 built its `existing` set by transforming the projection, so the plan could not contradict it. This package drives every statement off computeSeedPlan() against a declared measured fixture, and fails rather than rendering when fixture and document disagree. Change B is excluded mechanically, not editorially: phase 3 runs under the field scope PERSISTED_FIELDS minus scheduleELine, using the seed's existing SeedScope. No second scope mechanism and no per-code deny-list. The five rows surface as still-drifted in the same artifact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- docs/gates/coa-change-a-2026-10-21.md | 1156 +++++++++++++++++ scripts/coa-apply-gate-package.ts | 21 + scripts/coa-change-a-gate-package.ts | 830 ++++++++++++ .../coa-change-a-gate-package.test.ts | 290 +++++ 4 files changed, 2297 insertions(+) create mode 100644 docs/gates/coa-change-a-2026-10-21.md create mode 100644 scripts/coa-change-a-gate-package.ts create mode 100644 server/__tests__/coa-change-a-gate-package.test.ts diff --git a/docs/gates/coa-change-a-2026-10-21.md b/docs/gates/coa-change-a-2026-10-21.md new file mode 100644 index 0000000..4e1cfdf --- /dev/null +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -0,0 +1,1156 @@ +# Chart of accounts — Change A (structural) — §7 gate package + +Generated by `scripts/coa-change-a-gate-package.ts`. **Nothing in this run touched a +database and nothing in it needed a credential.** No statement below has been executed. +The apply remains operator-gated. + +The output is byte-deterministic: re-running the generator on the same tree reproduces +this file exactly, so a diff against a fresh run is a real check. + +## What this change is, and what it deliberately is not + +**Change A — structural.** Bring the live global chart (`tenant_id IS NULL`) up to the +authoritative document on everything that is not a tax-form mapping: the 25 accounts the +document defines and the table does not hold, the `parent_code` hierarchy, the one renamed +account, and the `description` drift. + +**Change B — form-line mapping. NOT IN THIS PACKAGE.** Five `schedule_e_line` drifts +(4110, 4120, 5020, 5030, 5080) are the tax-semantic class and ship as their own +independently auditable change, per §6. A structural reviewer is asking "is this account +in the right place in the tree?"; a form-line reviewer is asking "is Line 7 the right line +for 5020?". Put the two in one package and neither question gets asked properly. + +The exclusion is **mechanical, not editorial**: Change A runs under the field scope +`PERSISTED_FIELDS minus scheduleELine` = `name, type, subtype, description, taxDeductible, parentCode`. There is no +per-code deny-list to keep in sync — the column simply is not in the SET list the seed +can build. The five rows surface below under "deferred", as still-drifted, in this same +artifact, so a reader cannot mistake Change A for "the chart is now in sync". + +`type`, `subtype` and `taxDeductible` **are** inside Change A's scope and emit no +statement, because they are measured identical across all 80 common codes +(`taxDeductible` per-field digest `7b44da66cdfb` on both sides). +**There is no `tax_deductible` defect.** The earlier package +`scripts/coa-apply-gate-package.ts` (#214) asserted four `tax_deductible` UPDATEs on +5015/5025/5055/6050. Those four codes hold no row at all; they are INSERTs here, in +phase 2. Those UPDATEs would have matched zero rows and reported success. + +## §2 — the compiled statements that will execute, in order + +25 INSERT, 34 UPDATE, in three phases. + +Compiled by drizzle through `seedChartOfAccounts()` — the same code path the Worker +runs, and the only path that calls `assertDocumentParity()` before writing — against a +client that records the SQL and sends nothing. These are not transcriptions. §2 exists +because the 2026-09-18 hand-written apply was transcribed from a correct plan and dropped +a column. + +`$n` in the SQL is `params[n-1]`. Every bound value below is **real**, read off the +authoritative projection, with two exceptions that are labelled in place rather than +rendered as plausible values: + +- `updated_at` renders as ``. The apply stamps its own `new Date()`. +- an UPDATE's `id` renders as ``. The real id comes from the §4 + snapshot. The *shape* of the WHERE — id plus `tenant_id IS NULL` — is real compiled + output, and that is what makes a tenant override unreachable. + +No synthetic UUID and no fabricated timestamp appears anywhere in this package. An +operator who copies a value out of it either gets a working value or gets an obvious +placeholder; never a well-formed value that silently matches no row. + +### Phase 1 — the 10 rollup headers + +First, and not for tidiness: every one of the parent rewirings in phase 3, and some of +the inserts in phase 2, name one of these codes as a parent. `neon-http` commits each +statement independently, so an apply that failed partway with the order reversed would +leave children pointing at rows that do not exist. A code scope is used here because a +field scope cannot insert — an INSERT writes every column by definition, so calling it a +scoped change would be false. + +**1.1 INSERT 1090** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1090" +- `$3` = "Cash" +- `$4` = "asset" +- `$5` = "header" +- `$6` = "Header: rolls up the cash and bank accounts. Holds no transaction" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.2 INSERT 1190** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1190" +- `$3` = "Accounts Receivable" +- `$4` = "asset" +- `$5` = "header" +- `$6` = "Header: rolls up the receivables. Holds no transaction" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.3 INSERT 2590** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "2590" +- `$3` = "Mortgage Payable" +- `$4` = "liability" +- `$5` = "header" +- `$6` = "Header: rolls up the mortgage instruments (2500, 2510). Other borrowings are not children. Holds no transaction" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.4 INSERT 4090** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4090" +- `$3` = "Rental Income" +- `$4` = "income" +- `$5` = "header" +- `$6` = "Header: rolls up gross rents, Form 8825 line 2a. Holds no transaction" +- `$7` = "Line 3" +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.5 INSERT 4095** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4095" +- `$3` = "Tenant Fees" +- `$4` = "income" +- `$5` = "header" +- `$6` = "Header: rolls up other rental income, Form 8825 line 2b. Holds no transaction" +- `$7` = "Line 3" +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.6 INSERT 5190** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5190" +- `$3` = "Utilities" +- `$4` = "expense" +- `$5` = "header" +- `$6` = "Header: rolls up the landlord-paid utilities, Form 8825 line 12. Holds no transaction" +- `$7` = "Line 17" +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.7 INSERT 5290** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5290" +- `$3` = "Association Dues" +- `$4` = "expense" +- `$5` = "header" +- `$6` = "Header: rolls up HOA, condo and special assessment dues. Holds no transaction" +- `$7` = "Line 19" +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.8 INSERT 5390** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5390" +- `$3` = "Interest" +- `$4` = "expense" +- `$5` = "header" +- `$6` = "Header: rolls up interest onto Form 8825 line 8. No Schedule E line — its children split across lines 12 and 13, and the rollup must not merge them. Holds no transaction" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.9 INSERT 5490** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5490" +- `$3` = "Depreciation" +- `$4` = "expense" +- `$5` = "header" +- `$6` = "Header: rolls up depreciation, Form 8825 line 14. Holds no transaction" +- `$7` = "Line 18" +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**1.10 INSERT 7090** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "7090" +- `$3` = "Capital Improvements" +- `$4` = "expense" +- `$5` = "header" +- `$6` = "Header: rolls up capitalized improvements. Balance-sheet, like its children (§8). Holds no transaction" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + + +### Phase 2 — the 15 missing posting accounts + +Computed against the table phase 1 leaves behind. These rows carry the document's +`schedule_e_line` where it defines one — that is not a Change B write: an INSERT cannot +*change* a form-line mapping, because the row had none. Change B is about the five +EXISTING rows whose mapping is wrong, and none of the five is inserted here. + +**2.1 INSERT 1900** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1900" +- `$3` = "Transfer Clearing - Intra-Entity" +- `$4` = "asset" +- `$5` = "clearing" +- `$6` = "Both legs of a movement between two accounts of the same entity; nets to zero" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.2 INSERT 1910** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1910" +- `$3` = "Transfer Clearing - Intercompany" +- `$4` = "asset" +- `$5` = "clearing" +- `$6` = "Both legs of a movement between entities; nets to zero" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.3 INSERT 1920** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1920" +- `$3` = "Payment Rail Holding" +- `$4` = "asset" +- `$5` = "clearing" +- `$6` = "Venmo/Zelle/cash in flight until the far side is known" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.4 INSERT 1130** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "1130" +- `$3` = "Due from Affiliate" +- `$4` = "asset" +- `$5` = "receivable" +- `$6` = "Standing inter-entity receivable; mirrors the affiliate's 2540" +- `$7` = null +- `$8` = false +- `$9` = "1190" +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.5 INSERT 2045** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "2045" +- `$3` = "Buy-Now-Pay-Later Payable" +- `$4` = "liability" +- `$5` = "payable" +- `$6` = "Affirm, Afterpay and similar financed purchases" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.6 INSERT 2540** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "2540" +- `$3` = "Due to Affiliate" +- `$4` = "liability" +- `$5` = "payable" +- `$6` = "Standing inter-entity payable; mirrors the affiliate's 1130" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.7 INSERT 4005** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4005" +- `$3` = "Rental Income - Mid-Term Furnished" +- `$4` = "income" +- `$5` = null +- `$6` = "Furnished stays of 30+ days; passive rental income" +- `$7` = "Line 3" +- `$8` = false +- `$9` = "4090" +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.8 INSERT 4008** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4008" +- `$3` = "Rental Income - All-Inclusive" +- `$4` = "income" +- `$5` = null +- `$6` = "Rent with utilities bundled" +- `$7` = "Line 3" +- `$8` = false +- `$9` = "4090" +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.9 INSERT 4070** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4070" +- `$3` = "Management Income" +- `$4` = "income" +- `$5` = null +- `$6` = "Fees earned managing property for others; 1065 page 1 gross receipts" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.10 INSERT 4080** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "4080" +- `$3` = "Other Business Income" +- `$4` = "income" +- `$5` = null +- `$6` = "Amazon KDP and other non-rental revenue; 1065 page 1" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.11 INSERT 5015** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5015" +- `$3` = "Contract Labor (1099)" +- `$4` = "expense" +- `$5` = null +- `$6` = "Non-employee labor; 1099-NEC source. Not wages — Form 8825 line 17 Other" +- `$7` = "Line 19" +- `$8` = true +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.12 INSERT 5025** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5025" +- `$3` = "Furnishings & Decor" +- `$4` = "expense" +- `$5` = null +- `$6` = "Furnishings below the capitalization threshold; above it capitalize to 1610" +- `$7` = "Line 19" +- `$8` = true +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.13 INSERT 5055** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "5055" +- `$3` = "Litigation - Arias" +- `$4` = "expense" +- `$5` = null +- `$6` = "Litigation costs segregated from ordinary legal fees for the recovery waterfall" +- `$7` = "Line 10" +- `$8` = true +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.14 INSERT 6050** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "6050" +- `$3` = "AI & Compute" +- `$4` = "expense" +- `$5` = null +- `$6` = "Model APIs and compute (Anthropic, OpenAI and similar)" +- `$7` = "Line 19" +- `$8` = true +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + +**2.15 INSERT 9040** + +```sql +insert into "chart_of_accounts" ("id", "tenant_id", "code", "name", "type", "subtype", "description", "schedule_e_line", "tax_deductible", "parent_code", "is_active", "effective_date", "modified_by", "metadata", "created_at", "updated_at") values (default, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, default, $11, default, default, default) returning "id" +``` + +params: +- `$1` = null +- `$2` = "9040" +- `$3` = "Data Quality Hold" +- `$4` = "expense" +- `$5` = "suspense" +- `$6` = "Source data is broken (bad payee, epoch date); not a pending human decision" +- `$7` = null +- `$8` = false +- `$9` = null +- `$10` = true +- `$11` = "seed:chart-of-accounts" + + +### Phase 3 — the 34 structural updates + +Computed against the full 105-row table phases 1-2 leave behind, under the +field scope. Each SET list is exactly that row's changed fields plus `updated_at` — +built from `changedFields`, not hardcoded, which is what makes the scope affect the SQL +and not merely the plan. + +Breakdown: 34 `parent_code`, 1 `name`, 0 `description`. See §10 on why the description count here is a lower bound. + +**Where the 37 rewirings are.** The document gives 37 accounts a parent. +34 of them already exist and are rewired by an UPDATE here; +3 are among the 25 accounts phases 1-2 INSERT, and +arrive with their parent already set, so they need no UPDATE. 37 total either way — the +difference is which statement carries it. + +**3.1 UPDATE 1000 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1090" +- `$2` = +- `$3` = "" + +**3.2 UPDATE 1010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1090" +- `$2` = +- `$3` = "" + +**3.3 UPDATE 1020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1090" +- `$2` = +- `$3` = "" + +**3.4 UPDATE 1050 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1090" +- `$2` = +- `$3` = "" + +**3.5 UPDATE 1100 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1190" +- `$2` = +- `$3` = "" + +**3.6 UPDATE 1110 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "1190" +- `$2` = +- `$3` = "" + +**3.7 UPDATE 2500 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "2590" +- `$2` = +- `$3` = "" + +**3.8 UPDATE 2510 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "2590" +- `$2` = +- `$3` = "" + +**3.9 UPDATE 4000 — SET name, parent_code, updated_at** + +```sql +update "chart_of_accounts" set "name" = $1, "parent_code" = $2, "updated_at" = $3 where ("chart_of_accounts"."id" = $4 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "Rental Income - Long-Term" +- `$2` = "4090" +- `$3` = +- `$4` = "" + +**3.10 UPDATE 4010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.11 UPDATE 4020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.12 UPDATE 4030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.13 UPDATE 4040 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.14 UPDATE 4050 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.15 UPDATE 4060 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**3.16 UPDATE 5100 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**3.17 UPDATE 5110 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**3.18 UPDATE 5120 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**3.19 UPDATE 5130 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**3.20 UPDATE 5140 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**3.21 UPDATE 5200 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**3.22 UPDATE 5210 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**3.23 UPDATE 5220 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**3.24 UPDATE 5300 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5390" +- `$2` = +- `$3` = "" + +**3.25 UPDATE 5310 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5390" +- `$2` = +- `$3` = "" + +**3.26 UPDATE 5400 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**3.27 UPDATE 5410 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**3.28 UPDATE 5420 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**3.29 UPDATE 5430 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**3.30 UPDATE 7000 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**3.31 UPDATE 7010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**3.32 UPDATE 7020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**3.33 UPDATE 7030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**3.34 UPDATE 7040 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + + +### Deferred, in the same artifact + +Phase 3's field scope held back 5 row(s), each still drifted after Change A: + +- `4110` — nothing written · **still drifted on: scheduleELine** +- `4120` — nothing written · **still drifted on: scheduleELine** +- `5020` — nothing written · **still drifted on: scheduleELine** +- `5030` — nothing written · **still drifted on: scheduleELine** +- `5080` — nothing written · **still drifted on: scheduleELine** + +That is Change B, and it is owed. It is stated here rather than in a separate document +so that this package cannot be read as "the chart is in sync". + +## §4 — the pre-write snapshot, to run BEFORE phase 1 + +Required, not advisory. **Which primitive is in play:** the seed path as it stands awaits +`db.insert()` and `db.update()` one statement at a time, so over `neon-http` each one +commits independently and partial state is the default failure, not an edge case. There +is no transaction to abort. `db.batch()` *is* transactional on this driver +(`drizzle-orm/neon-http/session.js:131` → neon `client.transaction()`, which sets +`Neon-Batch-Isolation-Level`), and converting the seed's apply loop to it would make a +phase atomic — but that changes a production write path and belongs in its own PR, not +smuggled into a gate package. **Until then this snapshot is the only thing to go back to.** + +Reads every row and every column Change A touches, plus `updated_at` (which the apply also +writes), `id` (the UPDATE key), and `is_active`/`modified_by` (which the seed never writes — +captured so a rollback can prove it did not): + +```sql +-- 1. every global row, every column this change touches. Expect 80 rows. +SELECT id, code, name, type, subtype, description, schedule_e_line, + tax_deductible, parent_code, is_active, modified_by, updated_at + FROM chart_of_accounts + WHERE tenant_id IS NULL + ORDER BY code; +``` + +```sql +-- 2. insert-collision guard. Expect 0 rows. A non-zero result means a code this change +-- INSERTs already exists, the measured state is stale, and phase 1/2 must not run: +-- the table has no unique index on (tenant_id, code) for global rows, because +-- NULL != NULL in Postgres, so a duplicate would be created rather than rejected. +SELECT code, count(*) FROM chart_of_accounts + WHERE tenant_id IS NULL AND code IN ('1090', '1190', '2590', '4090', '4095', '5190', '5290', '5390', '5490', '7090', '1900', '1910', '1920', '1130', '2045', '2540', '4005', '4008', '4070', '4080', '5015', '5025', '5055', '6050', '9040') + GROUP BY code; +``` + +```sql +-- 3. tenant-scoped overrides of any code in this change. Expect: not checked yet (§10). +-- Every statement above is scoped `tenant_id IS NULL`, so these rows cannot be +-- written. This read exists to find out whether any exist to be left behind. +SELECT tenant_id, code, count(*) FROM chart_of_accounts + WHERE tenant_id IS NOT NULL GROUP BY tenant_id, code ORDER BY tenant_id, code; +``` + +Keep the output of all three. The rollback below is a template over query 1 and is +unusable without it. + +## §7 — rollback recipe + +### The UPDATEs (phase 3) — a true rollback exists + +For each row phase 3 wrote, restoring every column it set **and `updated_at`**: + +```sql +UPDATE chart_of_accounts + SET name = , + description = , + parent_code = , -- NULL restores to NULL + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; +``` + +`updated_at` is restored deliberately. A rollback that restores the value but not the +timestamp leaves the row looking recently and deliberately set, which corrupts the audit +trail as surely as the wrong value did. Restore only the columns that row's SET list +actually names — restoring a column the apply never wrote would itself be a write. + +### The INSERTs (phases 1-2) — there is NO true rollback. Stated, not papered over. + +An inserted row cannot be un-inserted: + +- `DELETE` is not a rollback. It is a destructive write that removes the row AND the + evidence that it ever existed, and if anything referenced the account in between — a + classification, a journal line — the delete either fails on the reference or orphans it. +- The seed path cannot express it. The seed converges *toward* the document, and the + document defines all 25 of these accounts; re-running it re-inserts them. + +**The compensating action is deactivation, not deletion:** `is_active = false` on the +inserted rows, which takes them out of every picker and report while leaving the row and +its audit trail intact. + +Two consequences an operator must know before authorizing, because neither is obvious: + +1. `is_active` is **not** a column the seed writes, in either direction — activation state + is an operational decision, not a chart definition. So deactivation is an operator + action outside the seed path, and it is the one write in this document that has no + §2-compliant path. Taking it means knowingly taking a §2 exception. +2. A deactivated row still **exists**. The next `computeSeedPlan` sees it as present, so + it classifies as UPDATE-or-unchanged, never again as an INSERT, and reports it under + `inactive`. Deactivating does not return the chart to its pre-Change-A plan. + +So the recovery path for phases 1-2 is forward, not backward. That is a reason to take +the §4 snapshot and to run the phases in order, not a reason to treat them as reversible. + +## §5 — reconcile AFTER, by digest, not by reading the list + +Eyeballing a list is how the original drift survived: a human reading eighty rows +reliably confirms what they expect to see. + +Same normalisation as the derivation — `NULL` → `''`, boolean → `'true'`/`'false'` — +so the digest and the plan cannot disagree about what "equal" means: + +```sql +SELECT count(*) AS rows, + substr(md5(string_agg( + code || ':' || coalesce(name,'') || ':' || coalesce(description,'') + || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'') + || ':' || tax_deductible::text, ',' ORDER BY code)), 1, 12) AS digest + FROM chart_of_accounts + WHERE tenant_id IS NULL; +``` + +| after | rows | digest | +|---|---|---| +| **Change A** (what this package delivers) | 105 | `f7cd982cbb00` | +| Change A **+ Change B** (the document in full) | 105 | `2db91a8332bb` | + +**Check against the first row, not the second.** The second is the projection's own +digest and it is the post-Change-B state: after a fully successful Change A it will NOT +match, and reading it as the target makes a correct apply look failed. The two differ +only in the five `schedule_e_line` values Change B owes. + +A mismatch localizes by bisecting the row set, not by re-reading it. A `rows` count below +105 with a mismatched digest means a phase committed partially — see §4 on +why that is the default failure here. + +## §7 — blast radius + +- **Statements:** 59 total — 25 INSERT (10 headers, then 15 posting accounts), 34 UPDATE. +- **Rows created:** 25. **Rows modified:** 34. All + `tenant_id IS NULL`. +- **Columns written on an UPDATE:** name, parent_code, updated_at. + Every SET list was read off the compiled SQL above, not assumed. +- **Never written by this path:** `is_active` (an operational decision, not a chart + definition), `modified_by` on an existing row (a row last touched by a human L4 auditor + keeps that attribution), `metadata`, and `schedule_e_line` on an existing row (held back + by the field scope — that is Change B). +- **Tenant overrides are unreachable:** every INSERT binds `tenant_id = null` and every + UPDATE carries `tenant_id IS NULL` alongside the row id, so a tenant row sharing a code + cannot be hit. Whether any such row exists is a §10 gap. + +**What reads `chart_of_accounts` downstream** — the surface this change moves: + +- `server/storage/system.ts` — the single DB access path; every COA read and the + `guardedWrite` audit gate go through it. +- `server/routes/classification.ts` — transaction classification; writes + `classification_audit` rows against an account code. New codes become classifiable the + moment phase 2 commits. +- `server/routes/admin.ts` → `POST /api/admin/seed/chart-of-accounts` — the operator-facing + scoped apply, i.e. the route that would execute this package. +- Reporting and tax derivation — `schedule_e_line` and `tax_deductible` feed Schedule E / + Form 8825 line derivation (`getForm8825Line()` resolves at read time). Change A moves + neither on an existing row; the 15 inserted posting accounts arrive carrying the + document's mapping. +- **`parent_code` consumers: none today.** The hierarchy is written by this change and read + by nothing yet, which is what makes the rewiring low-risk to apply and high-risk to get + wrong quietly — a wrong parent would surface only once a rollup is built on it. + +**Integrity of the resulting tree**, asserted by the generator against the document +(`assertMeasuredState()`), not rediscovered here: 0 dangling parent refs, 0 rows whose +parent is not a header, 0 headers carrying a parent, max depth exactly 1 (§1.7), 0 cycles. +The generator fails rather than rendering if any of those stops holding. + +## §10 — gaps in this package + +- **The §4 snapshot has not been taken.** This package contains no claim about what + production currently holds beyond the measured aggregates below. Every "current value" + question is open until it is run. +- **`description` drift was never isolated to a code list.** It was measured only in + aggregate, as part of "71 of 80 common codes drifted". So this package compiles + 0 description UPDATE(s) and the live apply will emit more. The + statement count here is a **lower bound**, and the §2 section says so where it counts + them. What is *not* uncertain: the value each description UPDATE binds comes from the + authoritative projection, so the digest in §5 is exact even though the statement list + is not. +- **`parent_code` starting values were not captured per row.** The fixture takes all 80 as + NULL. Where a row holds a STRAY parent the document clears (5050 and 9010 were cited as + examples), the live apply emits an UPDATE this package does not compile. So the + 34 compiled `parent_code` statements are the rows the document gives + a header, and the clears are additional. +- **The 9 measured-identical codes are not identified**, so the fixture's drifted-row count + is a lower bound on the measured 71. The three gaps above are the whole of the + difference; the apply recomputes from the live table and is unaffected by any of them. +- **Tenant-scoped overrides were not checked.** Nothing here can write one — every + statement is `tenant_id IS NULL` — but whether a tenant holds an override of any of + these 105 codes, which would then diverge from the global chart, is unknown. Query 3 in + §4 answers it. +- **`is_active` and `modified_by` on the 80 existing rows are unread.** The seed never + writes either; this package cannot say whether any existing row is already inactive. +- **Change B is unscheduled.** The five form-line drifts are stated, held back + mechanically, and owed. Nothing in this package fixes them and nothing in it schedules + the change that does. +- **The apply is blocked on a credential, not on this package.** `/api/admin/*` sits behind + `serviceAuth`, which needs the Worker's service token, whose resolution needs a + Cloudflare Access service principal. Routed through `/chico`; never resolved inline, and + not needed to produce or review this artifact. +- **Whether the deployed bundle carries the phased scoped-apply code is unverified.** The + 401 fires before body validation, so no unauthenticated probe can see the schema. +- **Not re-measured here.** The row counts, the 25 absent codes, the per-field digests and + the drift lists are carried as declared constants measured on 2026-10-21 and asserted + against the document. If production has moved since, §4 query 1 and 2 are what catch it + — this package will not. diff --git a/scripts/coa-apply-gate-package.ts b/scripts/coa-apply-gate-package.ts index 5a2cf36..ebd80ad 100644 --- a/scripts/coa-apply-gate-package.ts +++ b/scripts/coa-apply-gate-package.ts @@ -1,4 +1,25 @@ /** + * SUPERSEDED — DO NOT PRESENT THIS PACKAGE TO AN OPERATOR. + * + * Replaced by scripts/coa-change-a-gate-package.ts. This package compiles four UPDATEs over + * codes 5015/5025/5055/6050, on the premise that they carry a wrong `tax_deductible`. They + * do not: measured 2026-10-21, those four codes hold NO ROW in production — they were never + * inserted. The four UPDATEs would have matched zero rows and the apply would have reported + * success. `tax_deductible` is identical on all 80 common codes (per-field digest + * 7b44da66cdfb on both sides); there is no tax_deductible defect. + * + * The bug was not in computeSeedPlan. It was in assumedRows() below, which built the + * "existing" set by transforming the projection — inverting tax_deductible on the very codes + * the package then claimed were drifted. A plan derived from a fabricated `existing` set can + * assert anything, including a delta the live table contradicts. The replacement drives its + * plan off a DECLARED measured fixture and fails rather than rendering when that fixture and + * the authoritative document disagree. + * + * Kept, not deleted: it and docs/gates/coa-scoped-apply-2026-10-19.md are the record of an + * operator gate that was offered, and the evidence for why the derivation changed. + * + * --- + * * Produce the §7 gate package for a scoped chart-of-accounts apply. * * finance-operating-defaults §7 says a production financial write is operator-gated, and diff --git a/scripts/coa-change-a-gate-package.ts b/scripts/coa-change-a-gate-package.ts new file mode 100644 index 0000000..8c5dc60 --- /dev/null +++ b/scripts/coa-change-a-gate-package.ts @@ -0,0 +1,830 @@ +/** + * Produce the §7 gate package for "Change A" of the chart-of-accounts migration — + * the STRUCTURAL change: the 25 missing accounts, the parent_code hierarchy, the one + * renamed account, and the description drift. + * + * finance-operating-defaults §7 says a production financial write is operator-gated, and + * that the gate is only meaningful if what is presented is complete: the COMPILED statements + * (§2), the pre-write snapshot (§4), a rollback recipe covering every column touched + * including updated_at, the §5 reconciliation digest, and the blast radius. Asking an + * operator to approve a *described* change is not a gate — it is a request to trust a + * description. + * + * WHY THIS SUPERSEDES scripts/coa-apply-gate-package.ts + * + * That package (#214) compiled four UPDATEs over codes 5015/5025/5055/6050, asserting a + * `tax_deductible` defect on them. Those four codes hold NO ROW in production — they were + * never inserted. The four UPDATEs would have matched zero rows and the apply would have + * reported success. The defect was not in computeSeedPlan; it was in the FIXTURE the plan + * was computed against, which was built by inverting `tax_deductible` on rows the fixture + * itself invented. A plan derived from a fabricated `existing` set can assert anything. + * + * So the rule this package is built to: every statement is derived from + * `computeSeedPlan(projectSeedRows(), existing, scope)`, and `existing` is a declared + * MEASURED constant whose every assumption is named in §10 — never a transform of the + * projection that happens to produce the delta being claimed. + * + * Nothing here touches a database. Nothing here needs a credential. The output is + * byte-deterministic so the committed artifact can be diffed against a fresh run. + * + * npx tsx scripts/coa-change-a-gate-package.ts > docs/gates/coa-change-a-2026-10-21.md + */ +import { createHash } from 'node:crypto'; +import { drizzle } from 'drizzle-orm/neon-http'; +import { + computeSeedPlan, + projectSeedRows, + seedChartOfAccounts, + PERSISTED_FIELDS, + type ExistingAccountRow, + type PersistedField, + type SeedAccountRow, + type SeedPlan, + type SeedScope, +} from '../database/seeds/chart-of-accounts'; +import * as schema from '../database/system.schema'; + +// --------------------------------------------------------------------------- +// MEASURED STATE +// +// Measured against production `chart_of_accounts` on 2026-10-21. These are DATA, not +// derivations: nothing below is computed from the projection, because computing the +// "existing" set from the projection is precisely how #214 came to assert a plan the live +// table contradicted. Every constant here is asserted against the projection in +// assertMeasuredState() so a drifting document fails the generator rather than silently +// re-shaping the package. +// --------------------------------------------------------------------------- + +/** Phase 1: the ten rollup headers. Absent from production; every child points at one. */ +export const HEADER_CODES = [ + '1090', '1190', '2590', '4090', '4095', '5190', '5290', '5390', '5490', '7090', +] as const; + +/** Phase 2: the fifteen posting accounts absent from production. */ +export const POSTING_CODES = [ + '1900', '1910', '1920', '1130', '2045', '2540', '4005', '4008', '4070', '4080', + '5015', '5025', '5055', '6050', '9040', +] as const; + +/** Every code the projection defines and production does not hold. 10 + 15 = 25. */ +export const MISSING_CODES = [...HEADER_CODES, ...POSTING_CODES]; + +/** Rows production holds. Measured: 80, every one `tenant_id IS NULL`. */ +export const MEASURED_EXISTING_COUNT = 80; + +/** + * CHANGE B — form-line mapping, EXCLUDED from this package. + * + * §6 separates tax semantics from structure: a form-line change is the tax-semantic class + * and ships as its own independently auditable change, because its reviewer is asking a + * different question ("is Line 7 the right line for 5020?") than a structural reviewer is. + * Shipping the two together means neither question gets asked. + * + * The `from` values are the measured production values, carried here for exactly one + * reason: the drift must EXIST in the fixture, or the guard that proves the scope holds it + * back is asserting over an empty set and proves nothing. + */ +export const CHANGE_B_SCHEDULE_E: ReadonlyArray<{ code: string; from: string | null }> = [ + { code: '4110', from: null }, + { code: '4120', from: null }, + { code: '5020', from: 'Line 14' }, + { code: '5030', from: 'Line 7' }, + { code: '5080', from: 'Line 14' }, +]; + +/** The one measured `name` drift. */ +export const MEASURED_NAME_DRIFT = { code: '4000', from: 'Rental Income' } as const; + +/** + * Per-field aggregate digests measured on both sides for the 80 common codes. `type`, + * `subtype` and `taxDeductible` are IDENTICAL — which is why Change A emits no statement + * for them even though two of the three are inside its field scope. + * + * Recorded because "there is no tax_deductible defect" is the claim that retires #214, and + * a claim that retires a shipped package should carry its evidence. + */ +export const MEASURED_IDENTICAL_FIELDS: ReadonlyArray<{ field: PersistedField; digest: string }> = [ + { field: 'taxDeductible', digest: '7b44da66cdfb' }, +]; + +/** + * CHANGE A's field scope: every persisted field EXCEPT the form-line mapping. + * + * Expressed as a SUBTRACTION from PERSISTED_FIELDS rather than as an allow-list, because + * that is what §6 actually says — structure is everything that is not tax semantics. An + * allow-list silently drops any column PERSISTED_FIELDS later gains; a subtraction picks it + * up and forces whoever adds it to decide which class it belongs to. + */ +export const CHANGE_A_FIELDS: PersistedField[] = PERSISTED_FIELDS.filter( + (f) => f !== 'scheduleELine', +); + +// --------------------------------------------------------------------------- +// The fixture +// --------------------------------------------------------------------------- + +/** + * Production's 80 global rows, as measured. + * + * Built by removing the 25 measured-absent codes from the projection and then applying the + * measured drift. The ids are self-documenting strings rather than synthetic UUIDs: a + * synthetic UUID in a gate package is indistinguishable from a real one, and an operator + * who copies one into a WHERE clause writes nothing and sees success. + * + * Three assumptions, each named again in §10 because they bound what this package can show: + * - `parent_code` is taken as NULL on all 80. Production's per-row parent values were not + * captured. Where a row holds a STRAY parent the document clears to NULL (5050, 9010 + * were cited as examples), the live apply emits an UPDATE this package does not compile. + * - `description` is taken as matching the document on all 80. The description drift was + * measured only in aggregate and never isolated to a code list, so the live apply emits + * description UPDATEs this package does not compile. + * - The 9 measured-identical codes are not identified, so this fixture's drifted-row count + * is a LOWER BOUND on the measured 71. + * + * None of the three affects a compiled statement's SQL shape, SET list, WHERE clause, or + * bound value — an UPDATE binds the DESIRED value, which comes from the authoritative + * projection. They affect only WHICH rows appear. The snapshot in §4 resolves all three. + */ +export function measuredExistingRows(): ExistingAccountRow[] { + const absent = new Set(MISSING_CODES); + const scheduleEFrom = new Map(CHANGE_B_SCHEDULE_E.map((d) => [d.code, d.from])); + return projectSeedRows() + .filter((row: SeedAccountRow) => !absent.has(row.code)) + .map((row: SeedAccountRow): ExistingAccountRow => ({ + ...row, + id: ``, + isActive: true, + // Measured: production holds no parent_code on the rows this package compiles. + parentCode: null, + // Measured: the five Change B rows carry the wrong form line; everything else matches. + scheduleELine: scheduleEFrom.has(row.code) + ? (scheduleEFrom.get(row.code) ?? null) + : row.scheduleELine, + name: row.code === MEASURED_NAME_DRIFT.code ? MEASURED_NAME_DRIFT.from : row.name, + })); +} + +/** + * Fail the generator when the measured constants and the authoritative document disagree. + * + * This is the guard that makes #214's defect unrepeatable in kind. #214's fixture could not + * contradict its plan because the fixture WAS the plan's input, reshaped. Here the fixture + * is data, so it CAN contradict the document — and when it does, that is a fact about one + * of them, not something to render into an artifact an operator will sign. + */ +export function assertMeasuredState(projected: SeedAccountRow[]): void { + const byCode = new Map(projected.map((r) => [r.code, r])); + const fail = (m: string): never => { + throw new Error(`measured state contradicts the authoritative document: ${m}`); + }; + + if (projected.length !== 105) fail(`projection holds ${projected.length} accounts, measured 105`); + + const unknown = MISSING_CODES.filter((c) => !byCode.has(c)); + if (unknown.length) fail(`${unknown.length} measured-absent code(s) are not in the chart: ${unknown.join(', ')}`); + if (new Set(MISSING_CODES).size !== 25) fail(`measured-absent set is ${new Set(MISSING_CODES).size} codes, expected 25`); + + const documentHeaders = projected.filter((r) => r.subtype === 'header').map((r) => r.code).sort(); + const declaredHeaders = [...HEADER_CODES].sort(); + if (documentHeaders.join(',') !== declaredHeaders.join(',')) + fail(`the document's header accounts (${documentHeaders.join(', ')}) are not HEADER_CODES (${declaredHeaders.join(', ')})`); + + if (projected.length - MISSING_CODES.length !== MEASURED_EXISTING_COUNT) + fail(`105 - 25 is not the measured ${MEASURED_EXISTING_COUNT} existing rows`); + + for (const d of CHANGE_B_SCHEDULE_E) { + const row = byCode.get(d.code); + if (!row) fail(`Change B code ${d.code} is not in the chart`); + if (row!.scheduleELine === d.from) + fail(`Change B code ${d.code} is measured drifted from ${JSON.stringify(d.from)} but the document also says ${JSON.stringify(d.from)} — there is no drift to defer`); + } + const nameRow = byCode.get(MEASURED_NAME_DRIFT.code); + if (!nameRow) fail(`${MEASURED_NAME_DRIFT.code} is not in the chart`); + if (nameRow!.name === MEASURED_NAME_DRIFT.from) + fail(`${MEASURED_NAME_DRIFT.code} is measured drifted from "${MEASURED_NAME_DRIFT.from}" but the document says the same`); + + // §1.7 integrity, asserted rather than rediscovered. + const headers = new Set(HEADER_CODES); + const children = projected.filter((r) => r.parentCode !== null); + const dangling = children.filter((r) => !byCode.has(r.parentCode!)); + if (dangling.length) fail(`${dangling.length} dangling parent ref(s)`); + const nonHeaderParents = children.filter((r) => !headers.has(r.parentCode!)); + if (nonHeaderParents.length) fail(`${nonHeaderParents.length} row(s) point at a non-header parent`); + const parentedHeaders = projected.filter((r) => headers.has(r.code) && r.parentCode !== null); + if (parentedHeaders.length) fail(`${parentedHeaders.length} header(s) carry a parent`); + const tooDeep = children.filter((r) => byCode.get(r.parentCode!)!.parentCode !== null); + if (tooDeep.length) fail(`tree depth exceeds 1 on ${tooDeep.length} row(s)`); +} + +// --------------------------------------------------------------------------- +// Compilation against a recording client +// --------------------------------------------------------------------------- + +export interface CompiledStatement { + phase: 1 | 2 | 3; + kind: 'insert' | 'update'; + /** The account code this statement writes. */ + code: string; + sql: string; + /** Bind values in `$n` order. A Date is rendered, never stringified as a fake instant. */ + params: string[]; + /** The columns in an UPDATE's SET list, read off the compiled SQL. Empty for an INSERT. */ + setColumns: string[]; +} + +/** + * A bind value, rendered so nothing in the artifact reads as a real captured value. + * + * The timestamp case is not hypothetical and it is not cosmetic. The seed stamps + * `new Date()` at execution; drizzle's neon-http param mapper serializes it to an ISO + * string BEFORE the client sees it, so an `instanceof Date` check never fires and the + * generator's own clock reading lands in the artifact looking like a captured value. It + * also made the output non-deterministic, so a committed package could not be diffed + * against a fresh run. Matched on shape for that reason: no account name, description or + * code in the chart is an ISO-8601 instant. + */ +const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; + +function renderParam(value: unknown): string { + if (value instanceof Date) return ''; + if (typeof value === 'string' && ISO_INSTANT.test(value)) { + return ''; + } + return JSON.stringify(value); +} + +function setColumns(sql: string): string[] { + const clause = sql.split(/\bset\b/i)[1]?.split(/\bwhere\b/i)[0] ?? ''; + return [...clause.matchAll(/"([a-z_]+)" = /g)].map((m) => m[1]); +} + +const READBACK_COLUMNS: Record = { + id: 'id', code: 'code', name: 'name', type: 'type', subtype: 'subtype', + description: 'description', schedule_e_line: 'scheduleELine', + tax_deductible: 'taxDeductible', parent_code: 'parentCode', is_active: 'isActive', +}; + +/** + * Run the real seed path against a client that RECORDS each statement and sends nothing. + * + * The seed is the only sanctioned write path for the books (§2: never hand-write apply SQL) + * and the only thing that calls assertDocumentParity() before writing. Compiling through it + * means the statements in this artifact are drizzle's output from the same code the Worker + * runs — not a transcription of it, which is the failure mode §2 exists to prevent. + */ +async function compilePhase( + phase: 1 | 2 | 3, + existing: ExistingAccountRow[], + scope: SeedScope, +): Promise<{ plan: SeedPlan; statements: CompiledStatement[] }> { + const recorded: Array<{ sql: string; params: unknown[] }> = []; + const client = async (sql: string, params: unknown[]) => { + recorded.push({ sql, params }); + if (/^\s*select/i.test(sql)) { + const list = sql.slice(sql.search(/\bselect\b/i) + 6, sql.search(/\bfrom\b/i)); + const columns = [...list.matchAll(/"([a-z_]+)"/g)].map((m) => m[1]); + return { + rows: existing.map((r) => columns.map((c) => r[READBACK_COLUMNS[c]])), + rowCount: existing.length, + }; + } + // INSERT ... RETURNING "id". The seed reads inserted.id for its audit record, so a + // client that returns no row throws on the first header. #214's client returned none + // and never found out, because its scope compiled zero inserts. + if (/\breturning\b/i.test(sql)) { + return { rows: [['']], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }; + + // The seed logs "Applying chart of accounts: ... Wrote 10 insert(s)". Nothing was + // written — the client records and discards. Leaving that line in a gate package would + // be the single most misleading sentence in it. + const realLog = console.log; + console.log = () => {}; + let plan: SeedPlan; + try { + plan = await seedChartOfAccounts({ + apply: true, + db: drizzle(client as never, { schema }), + scope, + audit: async () => {}, + }); + } finally { + console.log = realLog; + } + + // Match each recorded DML statement to the plan entry that produced it. The seed writes + // every insert in plan order, then every update in plan order, so position is the join + // key — and the join is asserted, not assumed. + const dml = recorded.filter((c) => !/^\s*select/i.test(c.sql)); + const expected = plan.inserts.length + plan.updates.length; + if (dml.length !== expected) { + throw new Error( + `phase ${phase}: the plan holds ${plan.inserts.length} insert(s) and ${plan.updates.length} ` + + `update(s) but ${dml.length} statement(s) were compiled. The plan and the SQL have ` + + `diverged; this package must not render either.`, + ); + } + const statements: CompiledStatement[] = dml.map((c, i) => { + const isInsert = i < plan.inserts.length; + const code = isInsert + ? plan.inserts[i].code + : plan.updates[i - plan.inserts.length].row.code; + const kind: 'insert' | 'update' = isInsert ? 'insert' : 'update'; + if (kind === 'insert' !== /^\s*insert/i.test(c.sql)) { + throw new Error(`phase ${phase}: statement ${i + 1} is a ${kind} in the plan and not in the SQL`); + } + return { + phase, + kind, + code, + sql: c.sql, + params: c.params.map(renderParam), + setColumns: kind === 'update' ? setColumns(c.sql) : [], + }; + }); + return { plan, statements }; +} + +// --------------------------------------------------------------------------- +// §5 digest +// --------------------------------------------------------------------------- + +/** + * The normalisation the derivation uses, so the §5 digest and the plan cannot disagree + * about what "equal" means: NULL → '', boolean → 'true'/'false'. + */ +export function normaliseForDigest(value: string | boolean | null): string { + if (value === null) return ''; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return value; +} + +/** md5 of `code:name:description:parent:schedule_e:tax_deductible` joined by ',' ordered by code. */ +export function digestRows(rows: SeedAccountRow[]): string { + const body = [...rows] + .sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0)) + .map((r) => + [ + r.code, + normaliseForDigest(r.name), + normaliseForDigest(r.description), + normaliseForDigest(r.parentCode), + normaliseForDigest(r.scheduleELine), + normaliseForDigest(r.taxDeductible), + ].join(':'), + ) + .join(','); + return createHash('md5').update(body).digest('hex').slice(0, 12); +} + +/** + * The chart as it stands AFTER Change A and BEFORE Change B: the document on every column + * Change A writes, with the five form lines still at their measured production values. + * + * This is the only digest an operator can legitimately check after Change A. The + * projection's own digest is the post-Change-B state and would read as a failed apply. + */ +export function expectedPostChangeARows(): SeedAccountRow[] { + const held = new Map(CHANGE_B_SCHEDULE_E.map((d) => [d.code, d.from])); + return projectSeedRows().map((row) => + held.has(row.code) ? { ...row, scheduleELine: held.get(row.code) ?? null } : row, + ); +} + +// --------------------------------------------------------------------------- +// The package +// --------------------------------------------------------------------------- + +export interface ChangeAPackage { + statements: CompiledStatement[]; + plans: { phase1: SeedPlan; phase2: SeedPlan; phase3: SeedPlan }; + markdown: string; +} + +const sqlList = (codes: readonly string[]): string => codes.map((c) => `'${c}'`).join(', '); + +export async function buildChangeAPackage(): Promise { + const projected = projectSeedRows(); + assertMeasuredState(projected); + + const existing = measuredExistingRows(); + if (existing.length !== MEASURED_EXISTING_COUNT) { + throw new Error(`fixture holds ${existing.length} rows, measured ${MEASURED_EXISTING_COUNT}`); + } + + // Phase 1 — the ten headers. A CODE scope, which may insert; a field scope never can, + // because an INSERT writes every column by definition. + const p1 = await compilePhase(1, existing, { codes: [...HEADER_CODES] }); + + // Phase 2 — the fifteen posting accounts, against the table phase 1 leaves behind. Some + // of them carry a parent_code pointing at a phase 1 header, which is why the order is + // not cosmetic. + const afterP1 = [...existing, ...p1.plan.inserts.map(insertedRow)]; + const p2 = await compilePhase(2, afterP1, { codes: [...POSTING_CODES] }); + + // Phase 3 — the updates, against the full 105-row table phases 1-2 leave behind. A FIELD + // scope: every persisted column except the form-line mapping. This is the mechanism that + // holds Change B back, and it is the only one — there is no second list of excluded codes + // to keep in sync. + const afterP2 = [...afterP1, ...p2.plan.inserts.map(insertedRow)]; + const p3 = await compilePhase(3, afterP2, { fields: CHANGE_A_FIELDS }); + + const statements = [...p1.statements, ...p2.statements, ...p3.statements]; + const markdown = render({ projected, existing, p1, p2, p3, statements }); + return { + statements, + plans: { phase1: p1.plan, phase2: p2.plan, phase3: p3.plan }, + markdown, + }; +} + +/** A row phase 1/2 inserted, as phase 3 will read it back. */ +function insertedRow(row: SeedAccountRow): ExistingAccountRow { + return { ...row, id: ``, isActive: true }; +} + +function render(ctx: { + projected: SeedAccountRow[]; + existing: ExistingAccountRow[]; + p1: { plan: SeedPlan; statements: CompiledStatement[] }; + p2: { plan: SeedPlan; statements: CompiledStatement[] }; + p3: { plan: SeedPlan; statements: CompiledStatement[] }; + statements: CompiledStatement[]; +}): string { + const { projected, p1, p2, p3, statements } = ctx; + const out: string[] = []; + const h = (t: string) => out.push('', `## ${t}`, ''); + const parentUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('parentCode')); + const nameUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('name')); + const descUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('description')); + const documentChildren = projected.filter((r) => r.parentCode !== null).length; + + out.push( + '# Chart of accounts — Change A (structural) — §7 gate package', + '', + 'Generated by `scripts/coa-change-a-gate-package.ts`. **Nothing in this run touched a', + 'database and nothing in it needed a credential.** No statement below has been executed.', + 'The apply remains operator-gated.', + '', + 'The output is byte-deterministic: re-running the generator on the same tree reproduces', + 'this file exactly, so a diff against a fresh run is a real check.', + ); + + h('What this change is, and what it deliberately is not'); + out.push( + '**Change A — structural.** Bring the live global chart (`tenant_id IS NULL`) up to the', + 'authoritative document on everything that is not a tax-form mapping: the 25 accounts the', + 'document defines and the table does not hold, the `parent_code` hierarchy, the one renamed', + 'account, and the `description` drift.', + '', + '**Change B — form-line mapping. NOT IN THIS PACKAGE.** Five `schedule_e_line` drifts', + '(4110, 4120, 5020, 5030, 5080) are the tax-semantic class and ship as their own', + 'independently auditable change, per §6. A structural reviewer is asking "is this account', + 'in the right place in the tree?"; a form-line reviewer is asking "is Line 7 the right line', + 'for 5020?". Put the two in one package and neither question gets asked properly.', + '', + 'The exclusion is **mechanical, not editorial**: Change A runs under the field scope', + `\`PERSISTED_FIELDS minus scheduleELine\` = \`${CHANGE_A_FIELDS.join(', ')}\`. There is no`, + 'per-code deny-list to keep in sync — the column simply is not in the SET list the seed', + 'can build. The five rows surface below under "deferred", as still-drifted, in this same', + 'artifact, so a reader cannot mistake Change A for "the chart is now in sync".', + '', + '`type`, `subtype` and `taxDeductible` **are** inside Change A\'s scope and emit no', + 'statement, because they are measured identical across all 80 common codes', + `(\`taxDeductible\` per-field digest \`${MEASURED_IDENTICAL_FIELDS[0].digest}\` on both sides).`, + '**There is no `tax_deductible` defect.** The earlier package', + '`scripts/coa-apply-gate-package.ts` (#214) asserted four `tax_deductible` UPDATEs on', + '5015/5025/5055/6050. Those four codes hold no row at all; they are INSERTs here, in', + 'phase 2. Those UPDATEs would have matched zero rows and reported success.', + ); + + h('§2 — the compiled statements that will execute, in order'); + out.push( + `${statements.filter((s) => s.kind === 'insert').length} INSERT, ` + + `${statements.filter((s) => s.kind === 'update').length} UPDATE, in three phases.`, + '', + 'Compiled by drizzle through `seedChartOfAccounts()` — the same code path the Worker', + 'runs, and the only path that calls `assertDocumentParity()` before writing — against a', + 'client that records the SQL and sends nothing. These are not transcriptions. §2 exists', + 'because the 2026-09-18 hand-written apply was transcribed from a correct plan and dropped', + 'a column.', + '', + '`$n` in the SQL is `params[n-1]`. Every bound value below is **real**, read off the', + 'authoritative projection, with two exceptions that are labelled in place rather than', + 'rendered as plausible values:', + '', + '- `updated_at` renders as ``. The apply stamps its own `new Date()`.', + '- an UPDATE\'s `id` renders as ``. The real id comes from the §4', + ' snapshot. The *shape* of the WHERE — id plus `tenant_id IS NULL` — is real compiled', + ' output, and that is what makes a tenant override unreachable.', + '', + 'No synthetic UUID and no fabricated timestamp appears anywhere in this package. An', + 'operator who copies a value out of it either gets a working value or gets an obvious', + 'placeholder; never a well-formed value that silently matches no row.', + ); + + const phase = (n: 1 | 2 | 3, title: string, blurb: string[], sts: CompiledStatement[]) => { + out.push('', `### Phase ${n} — ${title}`, '', ...blurb, ''); + sts.forEach((s, i) => { + out.push( + s.kind === 'insert' + ? `**${n}.${i + 1} INSERT ${s.code}**` + : `**${n}.${i + 1} UPDATE ${s.code} — SET ${s.setColumns.join(', ')}**`, + '', + '```sql', + s.sql, + '```', + '', + 'params:', + ...s.params.map((p, j) => `- \`$${j + 1}\` = ${p}`), + '', + ); + }); + }; + + phase(1, `the ${p1.plan.inserts.length} rollup headers`, [ + 'First, and not for tidiness: every one of the parent rewirings in phase 3, and some of', + 'the inserts in phase 2, name one of these codes as a parent. `neon-http` commits each', + 'statement independently, so an apply that failed partway with the order reversed would', + 'leave children pointing at rows that do not exist. A code scope is used here because a', + 'field scope cannot insert — an INSERT writes every column by definition, so calling it a', + 'scoped change would be false.', + ], p1.statements); + + phase(2, `the ${p2.plan.inserts.length} missing posting accounts`, [ + 'Computed against the table phase 1 leaves behind. These rows carry the document\'s', + '`schedule_e_line` where it defines one — that is not a Change B write: an INSERT cannot', + '*change* a form-line mapping, because the row had none. Change B is about the five', + 'EXISTING rows whose mapping is wrong, and none of the five is inserted here.', + ], p2.statements); + + phase(3, `the ${p3.plan.updates.length} structural updates`, [ + `Computed against the full ${projected.length}-row table phases 1-2 leave behind, under the`, + 'field scope. Each SET list is exactly that row\'s changed fields plus `updated_at` —', + 'built from `changedFields`, not hardcoded, which is what makes the scope affect the SQL', + 'and not merely the plan.', + '', + `Breakdown: ${parentUpdates.length} \`parent_code\`, ${nameUpdates.length} \`name\`, ` + + `${descUpdates.length} \`description\`. See §10 on why the description count here is a` + + ' lower bound.', + '', + `**Where the 37 rewirings are.** The document gives ${documentChildren} accounts a parent.`, + `${parentUpdates.length} of them already exist and are rewired by an UPDATE here;`, + `${documentChildren - parentUpdates.length} are among the 25 accounts phases 1-2 INSERT, and`, + 'arrive with their parent already set, so they need no UPDATE. 37 total either way — the', + 'difference is which statement carries it.', + ], p3.statements); + + out.push( + '', + '### Deferred, in the same artifact', + '', + `Phase 3's field scope held back ${p3.plan.deferred.updates.length + p3.plan.deferred.fields.length}` + + ' row(s), each still drifted after Change A:', + '', + // Both buckets, because computeSeedPlan splits them: a row drifted ONLY on an + // out-of-scope column has nothing to write and lands in `deferred.updates`, while a + // row drifted on both lands in `deferred.fields`. All five Change B rows are the + // former. Rendering only `deferred.fields` printed "held back 0 row(s)" over a + // non-empty exclusion — a deferral report that silently reads as nothing deferred. + ...p3.plan.deferred.updates.map( + (u) => `- \`${u.row.code}\` — nothing written · **still drifted on: ${u.changedFields.join(', ')}**`, + ), + ...p3.plan.deferred.fields.map( + (f) => `- \`${f.code}\` — written: ${f.applied.join(', ')} · **still drifted on: ${f.deferred.join(', ')}**`, + ), + '', + 'That is Change B, and it is owed. It is stated here rather than in a separate document', + 'so that this package cannot be read as "the chart is in sync".', + ); + + h('§4 — the pre-write snapshot, to run BEFORE phase 1'); + out.push( + 'Required, not advisory. **Which primitive is in play:** the seed path as it stands awaits', + '`db.insert()` and `db.update()` one statement at a time, so over `neon-http` each one', + 'commits independently and partial state is the default failure, not an edge case. There', + 'is no transaction to abort. `db.batch()` *is* transactional on this driver', + '(`drizzle-orm/neon-http/session.js:131` → neon `client.transaction()`, which sets', + '`Neon-Batch-Isolation-Level`), and converting the seed\'s apply loop to it would make a', + 'phase atomic — but that changes a production write path and belongs in its own PR, not', + 'smuggled into a gate package. **Until then this snapshot is the only thing to go back to.**', + '', + 'Reads every row and every column Change A touches, plus `updated_at` (which the apply also', + 'writes), `id` (the UPDATE key), and `is_active`/`modified_by` (which the seed never writes —', + 'captured so a rollback can prove it did not):', + '', + '```sql', + '-- 1. every global row, every column this change touches. Expect 80 rows.', + 'SELECT id, code, name, type, subtype, description, schedule_e_line,', + ' tax_deductible, parent_code, is_active, modified_by, updated_at', + ' FROM chart_of_accounts', + ' WHERE tenant_id IS NULL', + ' ORDER BY code;', + '```', + '', + '```sql', + '-- 2. insert-collision guard. Expect 0 rows. A non-zero result means a code this change', + '-- INSERTs already exists, the measured state is stale, and phase 1/2 must not run:', + '-- the table has no unique index on (tenant_id, code) for global rows, because', + '-- NULL != NULL in Postgres, so a duplicate would be created rather than rejected.', + 'SELECT code, count(*) FROM chart_of_accounts', + ` WHERE tenant_id IS NULL AND code IN (${sqlList(MISSING_CODES)})`, + ' GROUP BY code;', + '```', + '', + '```sql', + '-- 3. tenant-scoped overrides of any code in this change. Expect: not checked yet (§10).', + '-- Every statement above is scoped `tenant_id IS NULL`, so these rows cannot be', + '-- written. This read exists to find out whether any exist to be left behind.', + 'SELECT tenant_id, code, count(*) FROM chart_of_accounts', + ' WHERE tenant_id IS NOT NULL GROUP BY tenant_id, code ORDER BY tenant_id, code;', + '```', + '', + 'Keep the output of all three. The rollback below is a template over query 1 and is', + 'unusable without it.', + ); + + h('§7 — rollback recipe'); + out.push( + '### The UPDATEs (phase 3) — a true rollback exists', + '', + 'For each row phase 3 wrote, restoring every column it set **and `updated_at`**:', + '', + '```sql', + 'UPDATE chart_of_accounts', + " SET name = ,", + " description = ,", + " parent_code = , -- NULL restores to NULL", + " updated_at = ''", + " WHERE id = '' AND tenant_id IS NULL;", + '```', + '', + '`updated_at` is restored deliberately. A rollback that restores the value but not the', + 'timestamp leaves the row looking recently and deliberately set, which corrupts the audit', + 'trail as surely as the wrong value did. Restore only the columns that row\'s SET list', + 'actually names — restoring a column the apply never wrote would itself be a write.', + '', + '### The INSERTs (phases 1-2) — there is NO true rollback. Stated, not papered over.', + '', + 'An inserted row cannot be un-inserted:', + '', + '- `DELETE` is not a rollback. It is a destructive write that removes the row AND the', + ' evidence that it ever existed, and if anything referenced the account in between — a', + ' classification, a journal line — the delete either fails on the reference or orphans it.', + '- The seed path cannot express it. The seed converges *toward* the document, and the', + ' document defines all 25 of these accounts; re-running it re-inserts them.', + '', + '**The compensating action is deactivation, not deletion:** `is_active = false` on the', + 'inserted rows, which takes them out of every picker and report while leaving the row and', + 'its audit trail intact.', + '', + 'Two consequences an operator must know before authorizing, because neither is obvious:', + '', + '1. `is_active` is **not** a column the seed writes, in either direction — activation state', + ' is an operational decision, not a chart definition. So deactivation is an operator', + ' action outside the seed path, and it is the one write in this document that has no', + ' §2-compliant path. Taking it means knowingly taking a §2 exception.', + '2. A deactivated row still **exists**. The next `computeSeedPlan` sees it as present, so', + ' it classifies as UPDATE-or-unchanged, never again as an INSERT, and reports it under', + ' `inactive`. Deactivating does not return the chart to its pre-Change-A plan.', + '', + 'So the recovery path for phases 1-2 is forward, not backward. That is a reason to take', + 'the §4 snapshot and to run the phases in order, not a reason to treat them as reversible.', + ); + + h('§5 — reconcile AFTER, by digest, not by reading the list'); + out.push( + 'Eyeballing a list is how the original drift survived: a human reading eighty rows', + 'reliably confirms what they expect to see.', + '', + 'Same normalisation as the derivation — `NULL` → `\'\'`, boolean → `\'true\'`/`\'false\'` —', + 'so the digest and the plan cannot disagree about what "equal" means:', + '', + '```sql', + 'SELECT count(*) AS rows,', + ' substr(md5(string_agg(', + " code || ':' || coalesce(name,'') || ':' || coalesce(description,'')", + " || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'')", + " || ':' || tax_deductible::text, ',' ORDER BY code)), 1, 12) AS digest", + ' FROM chart_of_accounts', + ' WHERE tenant_id IS NULL;', + '```', + '', + '| after | rows | digest |', + '|---|---|---|', + `| **Change A** (what this package delivers) | ${projected.length} | \`${digestRows(expectedPostChangeARows())}\` |`, + `| Change A **+ Change B** (the document in full) | ${projected.length} | \`${digestRows(projected)}\` |`, + '', + '**Check against the first row, not the second.** The second is the projection\'s own', + 'digest and it is the post-Change-B state: after a fully successful Change A it will NOT', + 'match, and reading it as the target makes a correct apply look failed. The two differ', + 'only in the five `schedule_e_line` values Change B owes.', + '', + 'A mismatch localizes by bisecting the row set, not by re-reading it. A `rows` count below', + `${projected.length} with a mismatched digest means a phase committed partially — see §4 on`, + 'why that is the default failure here.', + ); + + h('§7 — blast radius'); + const inserts = statements.filter((s) => s.kind === 'insert'); + const updates = statements.filter((s) => s.kind === 'update'); + out.push( + `- **Statements:** ${statements.length} total — ${inserts.length} INSERT ` + + `(${p1.plan.inserts.length} headers, then ${p2.plan.inserts.length} posting accounts), ` + + `${updates.length} UPDATE.`, + `- **Rows created:** ${inserts.length}. **Rows modified:** ${updates.length}. All`, + ' `tenant_id IS NULL`.', + `- **Columns written on an UPDATE:** ${[...new Set(updates.flatMap((s) => s.setColumns))].sort().join(', ')}.`, + ' Every SET list was read off the compiled SQL above, not assumed.', + '- **Never written by this path:** `is_active` (an operational decision, not a chart', + ' definition), `modified_by` on an existing row (a row last touched by a human L4 auditor', + ' keeps that attribution), `metadata`, and `schedule_e_line` on an existing row (held back', + ' by the field scope — that is Change B).', + '- **Tenant overrides are unreachable:** every INSERT binds `tenant_id = null` and every', + ' UPDATE carries `tenant_id IS NULL` alongside the row id, so a tenant row sharing a code', + ' cannot be hit. Whether any such row exists is a §10 gap.', + '', + '**What reads `chart_of_accounts` downstream** — the surface this change moves:', + '', + '- `server/storage/system.ts` — the single DB access path; every COA read and the', + ' `guardedWrite` audit gate go through it.', + '- `server/routes/classification.ts` — transaction classification; writes', + ' `classification_audit` rows against an account code. New codes become classifiable the', + ' moment phase 2 commits.', + '- `server/routes/admin.ts` → `POST /api/admin/seed/chart-of-accounts` — the operator-facing', + ' scoped apply, i.e. the route that would execute this package.', + '- Reporting and tax derivation — `schedule_e_line` and `tax_deductible` feed Schedule E /', + ' Form 8825 line derivation (`getForm8825Line()` resolves at read time). Change A moves', + ' neither on an existing row; the 15 inserted posting accounts arrive carrying the', + ' document\'s mapping.', + '- **`parent_code` consumers: none today.** The hierarchy is written by this change and read', + ' by nothing yet, which is what makes the rewiring low-risk to apply and high-risk to get', + ' wrong quietly — a wrong parent would surface only once a rollup is built on it.', + '', + '**Integrity of the resulting tree**, asserted by the generator against the document', + '(`assertMeasuredState()`), not rediscovered here: 0 dangling parent refs, 0 rows whose', + 'parent is not a header, 0 headers carrying a parent, max depth exactly 1 (§1.7), 0 cycles.', + 'The generator fails rather than rendering if any of those stops holding.', + ); + + h('§10 — gaps in this package'); + out.push( + '- **The §4 snapshot has not been taken.** This package contains no claim about what', + ' production currently holds beyond the measured aggregates below. Every "current value"', + ' question is open until it is run.', + '- **`description` drift was never isolated to a code list.** It was measured only in', + ` aggregate, as part of "71 of 80 common codes drifted". So this package compiles`, + ` ${descUpdates.length} description UPDATE(s) and the live apply will emit more. The`, + ' statement count here is a **lower bound**, and the §2 section says so where it counts', + ' them. What is *not* uncertain: the value each description UPDATE binds comes from the', + ' authoritative projection, so the digest in §5 is exact even though the statement list', + ' is not.', + '- **`parent_code` starting values were not captured per row.** The fixture takes all 80 as', + ' NULL. Where a row holds a STRAY parent the document clears (5050 and 9010 were cited as', + ` examples), the live apply emits an UPDATE this package does not compile. So the`, + ` ${parentUpdates.length} compiled \`parent_code\` statements are the rows the document gives`, + ' a header, and the clears are additional.', + '- **The 9 measured-identical codes are not identified**, so the fixture\'s drifted-row count', + ' is a lower bound on the measured 71. The three gaps above are the whole of the', + ' difference; the apply recomputes from the live table and is unaffected by any of them.', + '- **Tenant-scoped overrides were not checked.** Nothing here can write one — every', + ' statement is `tenant_id IS NULL` — but whether a tenant holds an override of any of', + ' these 105 codes, which would then diverge from the global chart, is unknown. Query 3 in', + ' §4 answers it.', + '- **`is_active` and `modified_by` on the 80 existing rows are unread.** The seed never', + ' writes either; this package cannot say whether any existing row is already inactive.', + '- **Change B is unscheduled.** The five form-line drifts are stated, held back', + ' mechanically, and owed. Nothing in this package fixes them and nothing in it schedules', + ' the change that does.', + '- **The apply is blocked on a credential, not on this package.** `/api/admin/*` sits behind', + ' `serviceAuth`, which needs the Worker\'s service token, whose resolution needs a', + ' Cloudflare Access service principal. Routed through `/chico`; never resolved inline, and', + ' not needed to produce or review this artifact.', + '- **Whether the deployed bundle carries the phased scoped-apply code is unverified.** The', + ' 401 fires before body validation, so no unauthenticated probe can see the schema.', + '- **Not re-measured here.** The row counts, the 25 absent codes, the per-field digests and', + ' the drift lists are carried as declared constants measured on 2026-10-21 and asserted', + ' against the document. If production has moved since, §4 query 1 and 2 are what catch it', + ' — this package will not.', + ); + + return out.join('\n') + '\n'; +} + +const invokedDirectly = + typeof process !== 'undefined' && + !!process.argv[1] && + /coa-change-a-gate-package\.ts$/.test(process.argv[1]); + +if (invokedDirectly) { + buildChangeAPackage() + .then((pkg) => { + process.stdout.write(pkg.markdown); + }) + .catch((e) => { + console.error('gate package failed:', e); + process.exit(1); + }); +} diff --git a/server/__tests__/coa-change-a-gate-package.test.ts b/server/__tests__/coa-change-a-gate-package.test.ts new file mode 100644 index 0000000..baaa344 --- /dev/null +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + buildChangeAPackage, + measuredExistingRows, + expectedPostChangeARows, + digestRows, + normaliseForDigest, + assertMeasuredState, + HEADER_CODES, + POSTING_CODES, + MISSING_CODES, + CHANGE_B_SCHEDULE_E, + CHANGE_A_FIELDS, + type CompiledStatement, +} from '../../scripts/coa-change-a-gate-package'; +import { + computeSeedPlan, + projectSeedRows, + PERSISTED_FIELDS, +} from '../../database/seeds/chart-of-accounts'; + +/** + * The gate package is the artifact an operator reads before authorizing a production + * financial write. §7 says the gate is only meaningful if what is presented is COMPLETE — + * the compiled statements, the snapshot, a rollback covering every column touched, the + * reconciliation digest, and the blast radius. A package that silently loses a section, or + * that quietly widens into the tax-semantic class, still LOOKS like a gate. + * + * These guards exist so that losing one fails instead. Each was mutation-tested: the thing + * it guards was broken and the guard was watched to fail. A guard never seen to fail is + * decoration. + */ +const ROOT = join(__dirname, '..', '..'); +const COMMITTED = join(ROOT, 'docs', 'gates', 'coa-change-a-2026-10-21.md'); + +/** The five existing rows whose form-line mapping is wrong. Change B, not Change A. */ +const CHANGE_B_CODES = CHANGE_B_SCHEDULE_E.map((d) => d.code); + +const pkg = await buildChangeAPackage(); + +describe('G3 — the package runs with no database and no credential', () => { + it('builds, and emits the 25 inserts as 10 headers then 15 posting accounts', () => { + const inserts = pkg.statements.filter((s) => s.kind === 'insert'); + expect(inserts).toHaveLength(25); + expect(inserts.filter((s) => s.phase === 1).map((s) => s.code)).toEqual([...HEADER_CODES]); + expect(inserts.filter((s) => s.phase === 2).map((s) => s.code)).toEqual([...POSTING_CODES]); + }); + + it('emits the parent, name and description updates in phase 3', () => { + const updates = pkg.statements.filter((s) => s.kind === 'update'); + expect(updates.length).toBeGreaterThan(0); + expect(updates.every((s) => s.phase === 3)).toBe(true); + // Every UPDATE writes updated_at and at least one Change A column, and nothing else. + for (const s of updates) { + expect(s.setColumns).toContain('updated_at'); + const written = s.setColumns.filter((c) => c !== 'updated_at'); + expect(written.length).toBeGreaterThan(0); + for (const col of written) { + expect(['name', 'type', 'subtype', 'description', 'parent_code', 'tax_deductible']) + .toContain(col); + } + } + expect(updates.some((s) => s.setColumns.includes('parent_code'))).toBe(true); + expect(updates.filter((s) => s.setColumns.includes('name')).map((s) => s.code)).toEqual(['4000']); + }); + + it('presents no placeholder UUID and no fabricated timestamp as a real value', () => { + // A synthetic UUID is indistinguishable from a real one, and an operator who copies one + // into a WHERE clause writes nothing and sees success. A fabricated instant is worse: + // it reads as a captured fact. Both must be visibly placeholders. + expect(pkg.markdown).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}/); + expect(pkg.markdown).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + for (const s of pkg.statements) { + for (const p of s.params) { + expect(p).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(p).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}/); + } + } + }); + + it('is byte-deterministic, so the committed artifact can be diffed against a fresh run', async () => { + const again = await buildChangeAPackage(); + expect(again.markdown).toBe(pkg.markdown); + }); + + it('carries every section §7 and §10 require', () => { + for (const heading of [ + '§2 — the compiled statements that will execute, in order', + '§4 — the pre-write snapshot, to run BEFORE phase 1', + '§7 — rollback recipe', + '§5 — reconcile AFTER, by digest', + '§7 — blast radius', + '§10 — gaps in this package', + ]) { + expect(pkg.markdown, `missing section: ${heading}`).toContain(heading); + } + // §10 must name the three gaps that bound what the package can show. + expect(pkg.markdown).toContain('never isolated to a code list'); + expect(pkg.markdown).toContain('Tenant-scoped overrides were not checked'); + expect(pkg.markdown).toContain('snapshot has not been taken'); + // §7 must not pretend the inserts are reversible. + expect(pkg.markdown).toContain('there is NO true rollback'); + expect(pkg.markdown).toContain('compensating action is deactivation, not deletion'); + expect(pkg.markdown).toContain('nothing in it needed a credential'); + }); + + it('matches the committed artifact', () => { + // The committed doc is what an operator reads. If it drifts from the generator, the + // generator is no longer evidence for it. + expect(readFileSync(COMMITTED, 'utf8')).toBe(pkg.markdown); + }); +}); + +describe('G4 — Change B is provably excluded', () => { + it('emits no statement that sets schedule_e_line on any of the five Change B codes', () => { + const touching: CompiledStatement[] = pkg.statements.filter((s) => + CHANGE_B_CODES.includes(s.code), + ); + // The five are existing rows, so nothing should INSERT them either. + expect(touching.every((s) => s.kind === 'update')).toBe(true); + for (const s of touching) { + expect(s.setColumns, `${s.code} must not have schedule_e_line in its SET list`) + .not.toContain('schedule_e_line'); + } + // And nowhere in the whole change does an UPDATE write that column, on any code. + for (const s of pkg.statements.filter((s) => s.kind === 'update')) { + expect(s.setColumns).not.toContain('schedule_e_line'); + } + }); + + it('holds the five back as still-drifted in the same artifact, rather than silently', () => { + // A scope that drops a change without saying so is how "the chart is now in sync" gets + // believed. The deferral must be in the operator-facing text. + const deferred = [ + ...pkg.plans.phase3.deferred.updates.map((u) => u.row.code), + ...pkg.plans.phase3.deferred.fields.map((f) => f.code), + ]; + expect(deferred.sort()).toEqual([...CHANGE_B_CODES].sort()); + for (const code of CHANGE_B_CODES) { + expect(pkg.markdown).toMatch(new RegExp(`\`${code}\`[^\\n]*still drifted on: scheduleELine`)); + } + }); + + it('excludes the form-line column by subtracting it from PERSISTED_FIELDS, not by a code list', () => { + // The mechanism matters: a per-code deny-list has to be kept in sync with the drift, + // and a code that drifts later is silently included. + expect(CHANGE_A_FIELDS).toEqual(PERSISTED_FIELDS.filter((f) => f !== 'scheduleELine')); + expect(CHANGE_A_FIELDS).not.toContain('scheduleELine'); + expect(pkg.plans.phase3.scope?.fields).toEqual(CHANGE_A_FIELDS); + }); + + it('inserting a new account with its form line is not a Change B write', () => { + // An INSERT cannot CHANGE a mapping — the row had none. The guard above is per-code for + // exactly this reason, and this pins the reasoning so a later reader does not "fix" it + // by stripping schedule_e_line out of the inserts and shipping 15 rows with no mapping. + const insertedWithMapping = projectSeedRows().filter( + (r) => MISSING_CODES.includes(r.code) && r.scheduleELine !== null, + ); + expect(insertedWithMapping.length).toBeGreaterThan(0); + for (const row of insertedWithMapping) { + expect(CHANGE_B_CODES).not.toContain(row.code); + } + }); +}); + +describe('G5 — the plan cannot be asserted against a contradicting table', () => { + /** + * The #214 defect, pinned so it cannot return. That package compiled four UPDATEs over + * 5015/5025/5055/6050 claiming a tax_deductible drift. Those codes hold no row, so the + * UPDATEs would have matched nothing and the apply would have reported success. The bug + * was the fixture, not computeSeedPlan — so the guard is: feed an `existing` set that + * lacks them and assert the plan calls them INSERTs. + */ + const ABSENT = ['5015', '5025', '5055', '6050']; + + it('classifies codes absent from `existing` as INSERTs, never UPDATEs', () => { + const existing = measuredExistingRows(); + expect(existing.map((r) => r.code).filter((c) => ABSENT.includes(c))).toEqual([]); + + const plan = computeSeedPlan(projectSeedRows(), existing); + for (const code of ABSENT) { + expect(plan.inserts.map((r) => r.code), `${code} must be an INSERT`).toContain(code); + expect(plan.updates.map((u) => u.row.code), `${code} must not be an UPDATE`).not.toContain(code); + } + }); + + it('compiles them as INSERTs in the package itself', () => { + for (const code of ABSENT) { + const sts = pkg.statements.filter((s) => s.code === code); + expect(sts).toHaveLength(1); + expect(sts[0].kind).toBe('insert'); + expect(sts[0].phase).toBe(2); + } + expect(pkg.markdown).toContain('There is no `tax_deductible` defect.'); + }); + + it('fails rather than rendering when the measured constants contradict the document', () => { + // The fixture is DATA, so it can contradict the document — and when it does, that is a + // fact about one of them, not something to render into an artifact an operator signs. + const projected = projectSeedRows(); + expect(() => assertMeasuredState(projected)).not.toThrow(); + expect(() => assertMeasuredState(projected.filter((r) => r.code !== '1090'))) + .toThrow(/contradicts the authoritative document/); + // A measured drift that is not actually a drift must also fail: the guard that proves + // Change B is held back would otherwise assert over an empty set. + const noNameDrift = projected.map((r) => + r.code === '4000' ? { ...r, name: 'Rental Income' } : r, + ); + expect(() => assertMeasuredState(noNameDrift)).toThrow(/there is no drift|says the same/); + }); + + it('refuses to render when the plan and the compiled SQL disagree in count', () => { + // The statement-to-plan join is positional. Asserted in compilePhase() rather than + // assumed, because a silent mismatch would attribute a statement to the wrong code — + // the same class of error as #214, one layer down. + expect(pkg.statements.filter((s) => s.phase === 1)).toHaveLength( + pkg.plans.phase1.inserts.length + pkg.plans.phase1.updates.length, + ); + expect(pkg.statements.filter((s) => s.phase === 3)).toHaveLength( + pkg.plans.phase3.inserts.length + pkg.plans.phase3.updates.length, + ); + }); +}); + +describe('G6 — ordering: a header exists before anything names it as a parent', () => { + it('places every header INSERT before every statement that references it', () => { + // neon-http commits each statement independently, so an apply that failed partway with + // the order reversed would leave children pointing at rows that do not exist. + for (const header of HEADER_CODES) { + const insertedAt = pkg.statements.findIndex( + (s) => s.kind === 'insert' && s.code === header, + ); + expect(insertedAt, `header ${header} is never inserted`).toBeGreaterThanOrEqual(0); + + const referencing = pkg.statements + .map((s, i) => ({ s, i })) + .filter(({ s, i }) => i !== insertedAt && s.params.includes(JSON.stringify(header))); + expect(referencing.length, `nothing references header ${header}`).toBeGreaterThan(0); + for (const { s, i } of referencing) { + expect(i, `${s.kind} ${s.code} references header ${header} before it exists`) + .toBeGreaterThan(insertedAt); + } + } + }); + + it('inserts all ten headers before the first posting-account insert', () => { + const firstPosting = pkg.statements.findIndex((s) => s.phase === 2); + const lastHeader = pkg.statements.map((s) => s.phase).lastIndexOf(1); + expect(lastHeader).toBeLessThan(firstPosting); + expect(pkg.statements.filter((s) => s.phase === 1)).toHaveLength(10); + }); + + it('leaves no statement referencing a parent that no statement and no existing row holds', () => { + const existingCodes = new Set(measuredExistingRows().map((r) => r.code)); + const children = projectSeedRows().filter((r) => r.parentCode !== null); + for (const child of children) { + const parent = child.parentCode!; + if (existingCodes.has(parent)) continue; + expect( + pkg.statements.some((s) => s.kind === 'insert' && s.code === parent), + `${child.code}'s parent ${parent} neither exists nor is inserted`, + ).toBe(true); + } + }); +}); + +describe('§5 — the digest uses the derivation\'s own normalisation', () => { + it('normalises NULL to the empty string and booleans to true/false', () => { + expect(normaliseForDigest(null)).toBe(''); + expect(normaliseForDigest(true)).toBe('true'); + expect(normaliseForDigest(false)).toBe('false'); + expect(normaliseForDigest('Line 7')).toBe('Line 7'); + }); + + it('targets the post-Change-A state, not the projection', () => { + // Checking the projection's digest after Change A reads a correct apply as failed: the + // two differ by exactly the five form lines Change B still owes. + const afterA = expectedPostChangeARows(); + const full = projectSeedRows(); + expect(digestRows(afterA)).not.toBe(digestRows(full)); + const differing = afterA.filter( + (r, i) => r.scheduleELine !== full.find((f) => f.code === r.code)!.scheduleELine, + ); + expect(differing.map((r) => r.code).sort()).toEqual([...CHANGE_B_CODES].sort()); + expect(pkg.markdown).toContain(digestRows(afterA)); + expect(pkg.markdown).toContain('Check against the first row, not the second.'); + }); +}); From 3bbdabaee753fb7293efbe5699be8a3b01392d79 Mon Sep 17 00:00:00 2001 From: NB <208086304+chitcommit@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:46:54 +0000 Subject: [PATCH 2/6] test(coa): fail loudly when the gate-package generator crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while mutation-testing G5: breaking the fixture made the whole test file report "no tests" instead of a failed assertion, because the module-level buildChangeAPackage() threw during collection. "no tests" greps as zero failures and reads as a pass in CI — the same silent-failure shape as a suite whose module never loaded. The build is captured into a value and asserted by the first test, so a crashing generator is one loud failure rather than twenty confusing ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- .../coa-change-a-gate-package.test.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/server/__tests__/coa-change-a-gate-package.test.ts b/server/__tests__/coa-change-a-gate-package.test.ts index baaa344..af346e1 100644 --- a/server/__tests__/coa-change-a-gate-package.test.ts +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -14,6 +14,7 @@ import { CHANGE_B_SCHEDULE_E, CHANGE_A_FIELDS, type CompiledStatement, + type ChangeAPackage, } from '../../scripts/coa-change-a-gate-package'; import { computeSeedPlan, @@ -38,7 +39,32 @@ const COMMITTED = join(ROOT, 'docs', 'gates', 'coa-change-a-2026-10-21.md'); /** The five existing rows whose form-line mapping is wrong. Change B, not Change A. */ const CHANGE_B_CODES = CHANGE_B_SCHEDULE_E.map((d) => d.code); -const pkg = await buildChangeAPackage(); +/** + * Captured rather than thrown at module scope. + * + * A generator that crashes during test COLLECTION means zero tests execute, and vitest + * reports "no tests" — which greps as zero failures and reads as a pass in CI. Observed + * while mutation-testing G5: breaking the fixture made the whole file report "no tests" + * instead of a failed assertion. So the failure is turned into a value here and asserted + * by the first test below. + */ +async function build(): Promise<{ pkg: ChangeAPackage | null; error: Error | null }> { + try { + return { pkg: await buildChangeAPackage(), error: null }; + } catch (e) { + return { pkg: null, error: e as Error }; + } +} +const built = await build(); + +it('the generator runs to completion', () => { + // First, so a crashing generator is one loud failure rather than twenty confusing ones + // — and never a "no tests" run that reads as green. + expect(built.error, built.error ? `generator failed: ${built.error.message}` : undefined) + .toBeNull(); +}); + +const pkg = built.pkg ?? ({ statements: [], plans: {}, markdown: '' } as unknown as ChangeAPackage); describe('G3 — the package runs with no database and no credential', () => { it('builds, and emits the 25 inserts as 10 headers then 15 posting accounts', () => { From ffc24981fea1908b381fd7f7c54300c254709028 Mon Sep 17 00:00:00 2001 From: NB Date: Wed, 21 Oct 2026 16:09:45 +0000 Subject: [PATCH 3/6] fix(coa): measure every parent_code, and pin the tree the updates leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change A is 25 inserts + 71 updates, not 25 + 34. The generator's fixture assumed `parent_code = NULL` on all 80 existing rows. That assumption AGREED with the desired state on the 37 rows the document leaves parentless, so those rows diffed to nothing and 37 of 71 parent changes were never compiled. Applying the 34 without the 37 would leave 5010 and 5050 parented to 5000 Advertising — a POSTING account — violating CHART-OF-ACCOUNTS §1.7 (parent must be a header, depth exactly 1) while the tree looked migrated. - MEASURED_PARENT_CODES: production's per-row parent, read-only, all 80. MEASURED_PARENT_CLEARS: the 37. assertMeasuredState() now fails unless the map covers exactly the 80 existing codes, the document-implied clears equal the measured 37, and there are exactly 34 rewires, all to inserted headers. - MEASURED_DESCRIPTION_DRIFT_CODES = 4000, 4110, measured, replacing the aggregate lower bound. Both are already phase-3 updates, so the count holds. - CHANGE_A_FIELDS subtracts taxDeductible as well as scheduleELine. §6 separates CLASSES of change, not diffs that happen to be empty; deductibility must not ride inside a hierarchy review. Kept as a subtraction from PERSISTED_FIELDS so a column added later forces a decision instead of being silently included. - §7 rollback is now 71 per-row statements carrying measured prior values, not a `` template. 4000's prior name is restored literally. Prior DESCRIPTIONS were never captured, so those two keep a placeholder and say so — a fabricated prior description is a second wrong write. - §10 drops the retired parent_code and description-lower-bound gaps; adds that prior descriptions live only in the snapshot; the 9 identical codes stay named as unlisted-by-code. Tests: every one of the 37 clears must emit an UPDATE binding parent_code to NULL; and the post-Change-A tree is reconstructed from the measured map, the bound update params and the inserted rows' parents, then held against §1.7 (parent is a header, depth exactly 1) and against the document projection. That whole-tree assertion is what was missing when the defect shipped. tax_deductible is pinned at the SCOPE, because it is measured identical and the SET-list assertion alone cannot fail today. Nothing was applied. The apply stays operator-gated on the Cloudflare Access service principal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- docs/gates/coa-change-a-2026-10-21.md | 931 ++++++++++++++++-- scripts/coa-change-a-gate-package.ts | 250 +++-- .../coa-change-a-gate-package.test.ts | 108 +- 3 files changed, 1153 insertions(+), 136 deletions(-) diff --git a/docs/gates/coa-change-a-2026-10-21.md b/docs/gates/coa-change-a-2026-10-21.md index 4e1cfdf..d4f62db 100644 --- a/docs/gates/coa-change-a-2026-10-21.md +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -21,13 +21,14 @@ in the right place in the tree?"; a form-line reviewer is asking "is Line 7 the for 5020?". Put the two in one package and neither question gets asked properly. The exclusion is **mechanical, not editorial**: Change A runs under the field scope -`PERSISTED_FIELDS minus scheduleELine` = `name, type, subtype, description, taxDeductible, parentCode`. There is no +`PERSISTED_FIELDS minus scheduleELine and taxDeductible` = `name, type, subtype, description, parentCode`. There is no per-code deny-list to keep in sync — the column simply is not in the SET list the seed can build. The five rows surface below under "deferred", as still-drifted, in this same artifact, so a reader cannot mistake Change A for "the chart is now in sync". -`type`, `subtype` and `taxDeductible` **are** inside Change A's scope and emit no -statement, because they are measured identical across all 80 common codes +`type` and `subtype` are inside Change A's scope and emit no statement, and +`taxDeductible` is outside it by class (the canonical tax-semantic column, §6) — it +would emit none either way, because all three are measured identical across all 80 common codes (`taxDeductible` per-field digest `7b44da66cdfb` on both sides). **There is no `tax_deductible` defect.** The earlier package `scripts/coa-apply-gate-package.ts` (#214) asserted four `tax_deductible` UPDATEs on @@ -36,7 +37,7 @@ phase 2. Those UPDATEs would have matched zero rows and reported success. ## §2 — the compiled statements that will execute, in order -25 INSERT, 34 UPDATE, in three phases. +25 INSERT, 71 UPDATE, in three phases. Compiled by drizzle through `seedChartOfAccounts()` — the same code path the Worker runs, and the only path that calls `assertDocumentParity()` before writing — against a @@ -550,20 +551,23 @@ params: - `$11` = "seed:chart-of-accounts" -### Phase 3 — the 34 structural updates +### Phase 3 — the 71 structural updates Computed against the full 105-row table phases 1-2 leave behind, under the field scope. Each SET list is exactly that row's changed fields plus `updated_at` — built from `changedFields`, not hardcoded, which is what makes the scope affect the SQL and not merely the plan. -Breakdown: 34 `parent_code`, 1 `name`, 0 `description`. See §10 on why the description count here is a lower bound. +Breakdown: 71 `parent_code`, 1 `name`, 2 `description` (exactly `4000` and `4110`, measured; those rows are also in the parent/name counts, so the total is unchanged). -**Where the 37 rewirings are.** The document gives 37 accounts a parent. -34 of them already exist and are rewired by an UPDATE here; -3 are among the 25 accounts phases 1-2 INSERT, and -arrive with their parent already set, so they need no UPDATE. 37 total either way — the -difference is which statement carries it. +**The 71 parent changes.** Production's per-row `parent_code` was measured +(read-only), not assumed: 34 existing rows are REWIRED to a header phases 1-2 insert, +and 37 are CLEARED (production holds a parent, the document says none), 0 rewired +to an existing code. The clears matter: without them 5010 and 5050 stay parented to 5000 +Advertising, a POSTING account, violating CHART-OF-ACCOUNTS §1.7 (a parent must be a +header; depth exactly 1) while the tree looks migrated. An earlier revision of this +package assumed every existing parent was NULL, so desired-NULL rows diffed empty and only +34 of 71 were compiled. **3.1 UPDATE 1000 — SET parent_code, updated_at** @@ -631,7 +635,139 @@ params: - `$2` = - `$3` = "" -**3.7 UPDATE 2500 — SET parent_code, updated_at** +**3.7 UPDATE 1510 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.8 UPDATE 1515 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.9 UPDATE 1520 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.10 UPDATE 1525 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.11 UPDATE 1610 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.12 UPDATE 1615 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.13 UPDATE 1620 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.14 UPDATE 1625 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.15 UPDATE 2010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.16 UPDATE 2020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.17 UPDATE 2030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.18 UPDATE 2040 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.19 UPDATE 2500 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -642,7 +778,7 @@ params: - `$2` = - `$3` = "" -**3.8 UPDATE 2510 — SET parent_code, updated_at** +**3.20 UPDATE 2510 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -653,19 +789,75 @@ params: - `$2` = - `$3` = "" -**3.9 UPDATE 4000 — SET name, parent_code, updated_at** +**3.21 UPDATE 2520 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.22 UPDATE 2530 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.23 UPDATE 3010 — SET parent_code, updated_at** ```sql -update "chart_of_accounts" set "name" = $1, "parent_code" = $2, "updated_at" = $3 where ("chart_of_accounts"."id" = $4 and "chart_of_accounts"."tenant_id" is null) +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.24 UPDATE 3020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.25 UPDATE 3030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.26 UPDATE 4000 — SET name, description, parent_code, updated_at** + +```sql +update "chart_of_accounts" set "name" = $1, "description" = $2, "parent_code" = $3, "updated_at" = $4 where ("chart_of_accounts"."id" = $5 and "chart_of_accounts"."tenant_id" is null) ``` params: - `$1` = "Rental Income - Long-Term" -- `$2` = "4090" -- `$3` = -- `$4` = "" +- `$2` = "Base rent received on unfurnished, year-length leases" +- `$3` = "4090" +- `$4` = +- `$5` = "" -**3.10 UPDATE 4010 — SET parent_code, updated_at** +**3.27 UPDATE 4010 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -676,7 +868,7 @@ params: - `$2` = - `$3` = "" -**3.11 UPDATE 4020 — SET parent_code, updated_at** +**3.28 UPDATE 4020 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -687,7 +879,7 @@ params: - `$2` = - `$3` = "" -**3.12 UPDATE 4030 — SET parent_code, updated_at** +**3.29 UPDATE 4030 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -698,7 +890,7 @@ params: - `$2` = - `$3` = "" -**3.13 UPDATE 4040 — SET parent_code, updated_at** +**3.30 UPDATE 4040 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -709,7 +901,7 @@ params: - `$2` = - `$3` = "" -**3.14 UPDATE 4050 — SET parent_code, updated_at** +**3.31 UPDATE 4050 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -720,7 +912,7 @@ params: - `$2` = - `$3` = "" -**3.15 UPDATE 4060 — SET parent_code, updated_at** +**3.32 UPDATE 4060 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -731,7 +923,129 @@ params: - `$2` = - `$3` = "" -**3.16 UPDATE 5100 — SET parent_code, updated_at** +**3.33 UPDATE 4110 — SET description, parent_code, updated_at** + +```sql +update "chart_of_accounts" set "description" = $1, "parent_code" = $2, "updated_at" = $3 where ("chart_of_accounts"."id" = $4 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = "Security deposits retained; income only on forfeiture, otherwise a 2010 liability" +- `$2` = null +- `$3` = +- `$4` = "" + +**3.34 UPDATE 4120 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.35 UPDATE 5010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.36 UPDATE 5020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.37 UPDATE 5030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.38 UPDATE 5040 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.39 UPDATE 5050 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.40 UPDATE 5060 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.41 UPDATE 5070 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.42 UPDATE 5080 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.43 UPDATE 5090 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.44 UPDATE 5100 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -742,7 +1056,7 @@ params: - `$2` = - `$3` = "" -**3.17 UPDATE 5110 — SET parent_code, updated_at** +**3.45 UPDATE 5110 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -753,7 +1067,7 @@ params: - `$2` = - `$3` = "" -**3.18 UPDATE 5120 — SET parent_code, updated_at** +**3.46 UPDATE 5120 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -764,7 +1078,7 @@ params: - `$2` = - `$3` = "" -**3.19 UPDATE 5130 — SET parent_code, updated_at** +**3.47 UPDATE 5130 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -775,7 +1089,7 @@ params: - `$2` = - `$3` = "" -**3.20 UPDATE 5140 — SET parent_code, updated_at** +**3.48 UPDATE 5140 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -786,7 +1100,7 @@ params: - `$2` = - `$3` = "" -**3.21 UPDATE 5200 — SET parent_code, updated_at** +**3.49 UPDATE 5200 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -797,7 +1111,7 @@ params: - `$2` = - `$3` = "" -**3.22 UPDATE 5210 — SET parent_code, updated_at** +**3.50 UPDATE 5210 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -808,7 +1122,7 @@ params: - `$2` = - `$3` = "" -**3.23 UPDATE 5220 — SET parent_code, updated_at** +**3.51 UPDATE 5220 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -819,7 +1133,7 @@ params: - `$2` = - `$3` = "" -**3.24 UPDATE 5300 — SET parent_code, updated_at** +**3.52 UPDATE 5300 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -830,7 +1144,7 @@ params: - `$2` = - `$3` = "" -**3.25 UPDATE 5310 — SET parent_code, updated_at** +**3.53 UPDATE 5310 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -841,7 +1155,29 @@ params: - `$2` = - `$3` = "" -**3.26 UPDATE 5400 — SET parent_code, updated_at** +**3.54 UPDATE 5320 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.55 UPDATE 5330 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.56 UPDATE 5400 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -852,7 +1188,7 @@ params: - `$2` = - `$3` = "" -**3.27 UPDATE 5410 — SET parent_code, updated_at** +**3.57 UPDATE 5410 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -863,7 +1199,7 @@ params: - `$2` = - `$3` = "" -**3.28 UPDATE 5420 — SET parent_code, updated_at** +**3.58 UPDATE 5420 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -874,7 +1210,7 @@ params: - `$2` = - `$3` = "" -**3.29 UPDATE 5430 — SET parent_code, updated_at** +**3.59 UPDATE 5430 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -885,7 +1221,51 @@ params: - `$2` = - `$3` = "" -**3.30 UPDATE 7000 — SET parent_code, updated_at** +**3.60 UPDATE 6010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.61 UPDATE 6020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.62 UPDATE 6030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.63 UPDATE 6040 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.64 UPDATE 7000 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -896,7 +1276,7 @@ params: - `$2` = - `$3` = "" -**3.31 UPDATE 7010 — SET parent_code, updated_at** +**3.65 UPDATE 7010 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -907,7 +1287,7 @@ params: - `$2` = - `$3` = "" -**3.32 UPDATE 7020 — SET parent_code, updated_at** +**3.66 UPDATE 7020 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -918,7 +1298,7 @@ params: - `$2` = - `$3` = "" -**3.33 UPDATE 7030 — SET parent_code, updated_at** +**3.67 UPDATE 7030 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -929,7 +1309,7 @@ params: - `$2` = - `$3` = "" -**3.34 UPDATE 7040 — SET parent_code, updated_at** +**3.68 UPDATE 7040 — SET parent_code, updated_at** ```sql update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) @@ -940,16 +1320,49 @@ params: - `$2` = - `$3` = "" +**3.69 UPDATE 9010 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.70 UPDATE 9020 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + +**3.71 UPDATE 9030 — SET parent_code, updated_at** + +```sql +update "chart_of_accounts" set "parent_code" = $1, "updated_at" = $2 where ("chart_of_accounts"."id" = $3 and "chart_of_accounts"."tenant_id" is null) +``` + +params: +- `$1` = null +- `$2` = +- `$3` = "" + ### Deferred, in the same artifact Phase 3's field scope held back 5 row(s), each still drifted after Change A: -- `4110` — nothing written · **still drifted on: scheduleELine** -- `4120` — nothing written · **still drifted on: scheduleELine** -- `5020` — nothing written · **still drifted on: scheduleELine** -- `5030` — nothing written · **still drifted on: scheduleELine** -- `5080` — nothing written · **still drifted on: scheduleELine** +- `4110` — written: description, parentCode · **still drifted on: scheduleELine** +- `4120` — written: parentCode · **still drifted on: scheduleELine** +- `5020` — written: parentCode · **still drifted on: scheduleELine** +- `5030` — written: parentCode · **still drifted on: scheduleELine** +- `5080` — written: parentCode · **still drifted on: scheduleELine** That is Change B, and it is owed. It is stated here rather than in a separate document so that this package cannot be read as "the chart is in sync". @@ -1001,23 +1414,386 @@ unusable without it. ## §7 — rollback recipe -### The UPDATEs (phase 3) — a true rollback exists +### The UPDATEs (phase 3) — a true rollback exists, one statement per row + +All 71, not a template. Each restores **exactly the columns that row's SET list +names** — restoring a column the apply never wrote would itself be a write — plus +`updated_at`. + +Every `parent_code` below is the MEASURED prior value, and `name` for +4000 is the measured prior `Rental Income`. So these are runnable as +soon as §4 query 1 supplies the row `id` and `updated_at`. + +`description` is the exception, stated rather than papered over: its prior value was +never captured — the drift was measured as a difference, not as a before-value — so the +two rows that write it (`4000` and `4110`) keep a `` +placeholder and cannot be rolled back until query 1 has run. A rollback that restored a +plausible-looking prior description would be a second wrong write dressed as a recovery. + +```sql +UPDATE chart_of_accounts -- 1000 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1010 + SET parent_code = '1000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1020 + SET parent_code = '1000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1050 + SET parent_code = '1000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1100 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1110 + SET parent_code = '1100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1510 + SET parent_code = '1500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1515 + SET parent_code = '1500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1520 + SET parent_code = '1500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1525 + SET parent_code = '1500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1610 + SET parent_code = '1600', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1615 + SET parent_code = '1600', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1620 + SET parent_code = '1600', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 1625 + SET parent_code = '1600', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2010 + SET parent_code = '2000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2020 + SET parent_code = '2000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2030 + SET parent_code = '2000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2040 + SET parent_code = '2000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2500 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2510 + SET parent_code = '2500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2520 + SET parent_code = '2500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 2530 + SET parent_code = '2500', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 3010 + SET parent_code = '3000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 3020 + SET parent_code = '3000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 3030 + SET parent_code = '3000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4000 + SET name = 'Rental Income', + description = , -- NOT captured; §4 query 1 + parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4010 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4020 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4030 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4040 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4050 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4060 + SET parent_code = '4000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4110 + SET description = , -- NOT captured; §4 query 1 + parent_code = '4100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 4120 + SET parent_code = '4100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5010 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5020 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5030 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5040 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5050 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5060 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5070 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5080 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5090 + SET parent_code = '5000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5100 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5110 + SET parent_code = '5100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5120 + SET parent_code = '5100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5130 + SET parent_code = '5100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5140 + SET parent_code = '5100', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5200 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5210 + SET parent_code = '5200', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5220 + SET parent_code = '5200', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5300 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5310 + SET parent_code = '5300', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5320 + SET parent_code = '5300', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5330 + SET parent_code = '5300', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5400 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5410 + SET parent_code = '5400', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5420 + SET parent_code = '5400', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 5430 + SET parent_code = '5400', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 6010 + SET parent_code = '6000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 6020 + SET parent_code = '6000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 6030 + SET parent_code = '6000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 6040 + SET parent_code = '6000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 7000 + SET parent_code = NULL, + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 7010 + SET parent_code = '7000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 7020 + SET parent_code = '7000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 7030 + SET parent_code = '7000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 7040 + SET parent_code = '7000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 9010 + SET parent_code = '9000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 9020 + SET parent_code = '9000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; + +UPDATE chart_of_accounts -- 9030 + SET parent_code = '9000', + updated_at = '' + WHERE id = '' AND tenant_id IS NULL; -For each row phase 3 wrote, restoring every column it set **and `updated_at`**: - -```sql -UPDATE chart_of_accounts - SET name = , - description = , - parent_code = , -- NULL restores to NULL - updated_at = '' - WHERE id = '' AND tenant_id IS NULL; ``` `updated_at` is restored deliberately. A rollback that restores the value but not the timestamp leaves the row looking recently and deliberately set, which corrupts the audit -trail as surely as the wrong value did. Restore only the columns that row's SET list -actually names — restoring a column the apply never wrote would itself be a write. +trail as surely as the wrong value did. ### The INSERTs (phases 1-2) — there is NO true rollback. Stated, not papered over. @@ -1080,10 +1856,10 @@ why that is the default failure here. ## §7 — blast radius -- **Statements:** 59 total — 25 INSERT (10 headers, then 15 posting accounts), 34 UPDATE. -- **Rows created:** 25. **Rows modified:** 34. All +- **Statements:** 96 total — 25 INSERT (10 headers, then 15 posting accounts), 71 UPDATE. +- **Rows created:** 25. **Rows modified:** 71. All `tenant_id IS NULL`. -- **Columns written on an UPDATE:** name, parent_code, updated_at. +- **Columns written on an UPDATE:** description, name, parent_code, updated_at. Every SET list was read off the compiled SQL above, not assumed. - **Never written by this path:** `is_active` (an operational decision, not a chart definition), `modified_by` on an existing row (a row last touched by a human L4 auditor @@ -1120,21 +1896,20 @@ The generator fails rather than rendering if any of those stops holding. - **The §4 snapshot has not been taken.** This package contains no claim about what production currently holds beyond the measured aggregates below. Every "current value" question is open until it is run. -- **`description` drift was never isolated to a code list.** It was measured only in - aggregate, as part of "71 of 80 common codes drifted". So this package compiles - 0 description UPDATE(s) and the live apply will emit more. The - statement count here is a **lower bound**, and the §2 section says so where it counts - them. What is *not* uncertain: the value each description UPDATE binds comes from the - authoritative projection, so the digest in §5 is exact even though the statement list - is not. -- **`parent_code` starting values were not captured per row.** The fixture takes all 80 as - NULL. Where a row holds a STRAY parent the document clears (5050 and 9010 were cited as - examples), the live apply emits an UPDATE this package does not compile. So the - 34 compiled `parent_code` statements are the rows the document gives - a header, and the clears are additional. -- **The 9 measured-identical codes are not identified**, so the fixture's drifted-row count - is a lower bound on the measured 71. The three gaps above are the whole of the - difference; the apply recomputes from the live table and is unaffected by any of them. +- **`description` drift is measured exactly, not bounded.** Production was compared to the + document on all 80 common codes: `4000` and `4110` drift, + the other 78 match byte-for-byte. Both are already phase-3 updates, so this package + compiles 2 description write(s) and the statement count is unchanged. + Production's prior description for those two is not in this package; §4 records it. +- **Prior `description` values exist only in the snapshot.** The description drift was + measured as a difference, not as a before-value, so this package holds what those two + rows will BECOME and not what they are. The §7 rollback for them is therefore unusable + until §4 query 1 has run. Every other rollback value here is measured. +- **The 9 measured-identical codes are not identified by code.** 80 common codes minus the + 71 compiled here leaves 9, which agrees with the measurement — but the measurement + reported a count, not a list, so the identity of those 9 is inferred from this package + rather than confirmed against production. §4 query 1 settles it; the apply recomputes + from the live table and is unaffected either way. - **Tenant-scoped overrides were not checked.** Nothing here can write one — every statement is `tenant_id IS NULL` — but whether a tenant holds an override of any of these 105 codes, which would then diverge from the global chart, is unknown. Query 3 in diff --git a/scripts/coa-change-a-gate-package.ts b/scripts/coa-change-a-gate-package.ts index 8c5dc60..a88d2bf 100644 --- a/scripts/coa-change-a-gate-package.ts +++ b/scripts/coa-change-a-gate-package.ts @@ -97,8 +97,8 @@ export const MEASURED_NAME_DRIFT = { code: '4000', from: 'Rental Income' } as co /** * Per-field aggregate digests measured on both sides for the 80 common codes. `type`, - * `subtype` and `taxDeductible` are IDENTICAL — which is why Change A emits no statement - * for them even though two of the three are inside its field scope. + * `subtype` and `taxDeductible` are IDENTICAL. `type` and `subtype` are inside Change A's + * scope and emit nothing because nothing drifts; `taxDeductible` is outside it by class (§6). * * Recorded because "there is no tax_deductible defect" is the claim that retires #214, and * a claim that retires a shipped package should carry its evidence. @@ -108,15 +108,60 @@ export const MEASURED_IDENTICAL_FIELDS: ReadonlyArray<{ field: PersistedField; d ]; /** - * CHANGE A's field scope: every persisted field EXCEPT the form-line mapping. + * The measured `description` drift: exactly these two codes, out of the 80 common codes. + * Every other common code matches the document byte-for-byte. Both are already among the + * phase-3 updates (4000: name + parent; 4110: parent), so this adds `description` to their + * SET lists and does not change the statement count. + * + * `from` is NOT production's prior value — that was not captured. It is a marker that + * differs from every document description, so the plan sees a drift; the real prior value + * is what the §4 snapshot records. An UPDATE binds the DESIRED value, so the marker never + * reaches a compiled statement. + */ +export const MEASURED_DESCRIPTION_DRIFT_CODES = ['4000', '4110'] as const; +const DESCRIPTION_DRIFT_MARKER = ''; + +/** + * Production's `parent_code` on each of the 80 existing global rows, MEASURED read-only + * (code>parent, NULL = column null). This replaces the earlier assumption that every + * existing row held a NULL parent: that assumption made every desired-NULL row look + * already-correct, so the 37 CLEARS (production holds a parent, the document says none) + * diffed empty and were never compiled — 34 of 71 parent changes, a half-migrated tree in + * which 5010 and 5050 stay parented to the POSTING account 5000 (§1.7 violation). + */ +export const MEASURED_PARENT_CODES: ReadonlyMap = new Map( + ( + '1000>NULL 1010>1000 1020>1000 1050>1000 1100>NULL 1110>1100 1500>NULL 1510>1500 1515>1500 1520>1500 1525>1500 1600>NULL 1605>NULL 1610>1600 1615>1600 1620>1600 1625>1600 2000>NULL 2010>2000 2020>2000 2030>2000 2040>2000 2500>NULL 2510>2500 2520>2500 2530>2500 3000>NULL 3010>3000 3020>3000 3030>3000 4000>NULL 4010>4000 4020>4000 4030>4000 4040>4000 4050>4000 4060>4000 4100>NULL 4110>4100 4120>4100 5000>NULL 5010>5000 5020>5000 5030>5000 5040>5000 5050>5000 5060>5000 5070>5000 5080>5000 5090>5000 5100>NULL 5110>5100 5120>5100 5130>5100 5140>5100 5200>NULL 5210>5200 5220>5200 5300>NULL 5310>5300 5320>5300 5330>5300 5400>NULL 5410>5400 5420>5400 5430>5400 6000>NULL 6010>6000 6020>6000 6030>6000 6040>6000 7000>NULL 7010>7000 7020>7000 7030>7000 7040>7000 9000>NULL 9010>9000 9020>9000 9030>9000' + ) + .split(' ') + .map((pair): [string, string | null] => { + const [code, parent] = pair.split('>'); + return [code, parent === 'NULL' ? null : parent]; + }), +); + +/** The 37 measured clears: production holds a parent, the document says none. */ +export const MEASURED_PARENT_CLEARS = '1510 1515 1520 1525 1610 1615 1620 1625 2010 2020 2030 2040 2520 2530 3010 3020 3030 4110 4120 5010 5020 5030 5040 5050 5060 5070 5080 5090 5320 5330 6010 6020 6030 6040 9010 9020 9030'.split(' '); + +/** + * CHANGE A's field scope: every persisted field EXCEPT the two tax-semantic columns, + * `scheduleELine` and `taxDeductible`. * * Expressed as a SUBTRACTION from PERSISTED_FIELDS rather than as an allow-list, because * that is what §6 actually says — structure is everything that is not tax semantics. An * allow-list silently drops any column PERSISTED_FIELDS later gains; a subtraction picks it * up and forces whoever adds it to decide which class it belongs to. + * + * `taxDeductible` is subtracted although it emits zero statements today. finance-operating- + * defaults §6 separates CLASSES of change, not diffs that happen to be empty: it is the + * canonical tax-semantic column, and it is empty only because it was measured identical + * across all 80 common codes. If the document changes before the apply runs, a deductibility + * write would otherwise ride inside a structural hierarchy review — the §6 failure mode, "a + * deductibility change must never ride inside a hierarchy change, where review attention is + * on the tree". */ export const CHANGE_A_FIELDS: PersistedField[] = PERSISTED_FIELDS.filter( - (f) => f !== 'scheduleELine', + (f) => f !== 'scheduleELine' && f !== 'taxDeductible', ); // --------------------------------------------------------------------------- @@ -131,36 +176,30 @@ export const CHANGE_A_FIELDS: PersistedField[] = PERSISTED_FIELDS.filter( * synthetic UUID in a gate package is indistinguishable from a real one, and an operator * who copies one into a WHERE clause writes nothing and sees success. * - * Three assumptions, each named again in §10 because they bound what this package can show: - * - `parent_code` is taken as NULL on all 80. Production's per-row parent values were not - * captured. Where a row holds a STRAY parent the document clears to NULL (5050, 9010 - * were cited as examples), the live apply emits an UPDATE this package does not compile. - * - `description` is taken as matching the document on all 80. The description drift was - * measured only in aggregate and never isolated to a code list, so the live apply emits - * description UPDATEs this package does not compile. - * - The 9 measured-identical codes are not identified, so this fixture's drifted-row count - * is a LOWER BOUND on the measured 71. - * - * None of the three affects a compiled statement's SQL shape, SET list, WHERE clause, or - * bound value — an UPDATE binds the DESIRED value, which comes from the authoritative - * projection. They affect only WHICH rows appear. The snapshot in §4 resolves all three. + * `parent_code` (MEASURED_PARENT_CODES) and `description` + * (MEASURED_DESCRIPTION_DRIFT_CODES) are measured exactly. One assumption remains: the 9 + * measured-identical codes are not identified, so the drifted-row count is checked against + * the measured total only where the measurement is per-row. */ export function measuredExistingRows(): ExistingAccountRow[] { const absent = new Set(MISSING_CODES); const scheduleEFrom = new Map(CHANGE_B_SCHEDULE_E.map((d) => [d.code, d.from])); + const descDrift = new Set(MEASURED_DESCRIPTION_DRIFT_CODES); return projectSeedRows() .filter((row: SeedAccountRow) => !absent.has(row.code)) .map((row: SeedAccountRow): ExistingAccountRow => ({ ...row, id: ``, isActive: true, - // Measured: production holds no parent_code on the rows this package compiles. - parentCode: null, + // Measured per row (MEASURED_PARENT_CODES); never assumed. + parentCode: MEASURED_PARENT_CODES.get(row.code) ?? null, // Measured: the five Change B rows carry the wrong form line; everything else matches. scheduleELine: scheduleEFrom.has(row.code) ? (scheduleEFrom.get(row.code) ?? null) : row.scheduleELine, name: row.code === MEASURED_NAME_DRIFT.code ? MEASURED_NAME_DRIFT.from : row.name, + // Measured: exactly two codes drift on description; the rest match the document. + description: descDrift.has(row.code) ? DESCRIPTION_DRIFT_MARKER : row.description, })); } @@ -180,6 +219,7 @@ export function assertMeasuredState(projected: SeedAccountRow[]): void { if (projected.length !== 105) fail(`projection holds ${projected.length} accounts, measured 105`); + const absentSet = new Set(MISSING_CODES); const unknown = MISSING_CODES.filter((c) => !byCode.has(c)); if (unknown.length) fail(`${unknown.length} measured-absent code(s) are not in the chart: ${unknown.join(', ')}`); if (new Set(MISSING_CODES).size !== 25) fail(`measured-absent set is ${new Set(MISSING_CODES).size} codes, expected 25`); @@ -203,6 +243,39 @@ export function assertMeasuredState(projected: SeedAccountRow[]): void { if (nameRow!.name === MEASURED_NAME_DRIFT.from) fail(`${MEASURED_NAME_DRIFT.code} is measured drifted from "${MEASURED_NAME_DRIFT.from}" but the document says the same`); + if (new Set(MEASURED_DESCRIPTION_DRIFT_CODES).size !== 2) + fail('measured description-drift set is not exactly 2 codes'); + for (const code of MEASURED_DESCRIPTION_DRIFT_CODES) { + const row = byCode.get(code); + if (!row) fail(`description-drift code ${code} is not in the chart`); + if (row!.description === DESCRIPTION_DRIFT_MARKER) + fail(`description-drift code ${code} is measured drifted but the document holds the drift marker`); + if (absentSet.has(code)) fail(`description-drift code ${code} is measured absent, so it cannot drift`); + } + + // The measured parent map must cover exactly the 80 existing codes and must not contradict + // the document's shape: every rewire targets a header this change inserts (0 target an + // existing code), every clear is a code the document leaves parentless. + const existingCodes = projected.map((r) => r.code).filter((c) => !absentSet.has(c)).sort(); + const measuredCodes = [...MEASURED_PARENT_CODES.keys()].sort(); + if (existingCodes.join(',') !== measuredCodes.join(',')) + fail('the measured parent map does not cover exactly the 80 existing codes'); + const clears: string[] = []; + let rewires = 0; + for (const code of existingCodes) { + const have = MEASURED_PARENT_CODES.get(code) ?? null; + const want = byCode.get(code)!.parentCode; + if (have === want) continue; + if (want === null) clears.push(code); + else { + rewires++; + if (!absentSet.has(want)) fail(`${code} is measured rewired to existing code ${want}; measured 0 such rewires`); + } + } + if (clears.sort().join(',') !== [...MEASURED_PARENT_CLEARS].sort().join(',')) + fail(`document clears [${clears.join(' ')}] are not the measured 37 clears`); + if (rewires !== 34) fail(`${rewires} parent rewires, measured 34`); + // §1.7 integrity, asserted rather than rediscovered. const headers = new Set(HEADER_CODES); const children = projected.filter((r) => r.parentCode !== null); @@ -405,6 +478,60 @@ export interface ChangeAPackage { const sqlList = (codes: readonly string[]): string => codes.map((c) => `'${c}'`).join(', '); +/** A measured value as a SQL literal. `null` is the SQL keyword, never the string 'null'. */ +const sqlLiteral = (v: string | null): string => (v === null ? 'NULL' : `'${v.replace(/'/g, "''")}'`); + +/** + * The rollback for ONE row phase 3 writes: per row, not a template. + * + * Restores exactly the columns that row's SET list names, plus `updated_at`. Restoring a + * column the apply never wrote would itself be a write. + * + * `parent_code` and `name` carry their MEASURED prior values, so those statements are + * runnable the moment the §4 snapshot supplies the row `id`. `description` does NOT: the + * drift was measured as a difference, never as a before-value, so it keeps a placeholder. + * Said, not filled with something plausible — a rollback that restores a fabricated prior + * description is a second wrong write dressed as a recovery. + * + * Any other changed field throws. A gate package must not render a rollback it cannot fill. + */ +function rollbackStatement(u: { row: SeedAccountRow; changedFields: PersistedField[] }): string[] { + const code = u.row.code; + const assignments: string[] = []; + for (const field of u.changedFields) { + if (field === 'parentCode') { + assignments.push(`parent_code = ${sqlLiteral(MEASURED_PARENT_CODES.get(code) ?? null)}`); + } else if (field === 'name') { + if (code !== MEASURED_NAME_DRIFT.code) { + throw new Error( + `rollback: ${code} changes name, but only ${MEASURED_NAME_DRIFT.code}'s prior name was measured`, + ); + } + assignments.push(`name = ${sqlLiteral(MEASURED_NAME_DRIFT.from)}`); + } else if (field === 'description') { + assignments.push(`description = -- NOT captured; §4 query 1`); + } else { + throw new Error( + `rollback: no measured prior value for ${code}.${field}; this package must not render ` + + 'a rollback it cannot fill', + ); + } + } + assignments.push(`updated_at = ''`); + return [ + `UPDATE chart_of_accounts -- ${code}`, + ...assignments.map((a, i) => { + const lead = i === 0 ? ' SET' : ' '; + const comma = i === assignments.length - 1 ? '' : ','; + return a.includes('--') + ? `${lead} ${a.replace(' -- ', `${comma} -- `)}` + : `${lead} ${a}${comma}`; + }), + ` WHERE id = '' AND tenant_id IS NULL;`, + '', + ]; +} + export async function buildChangeAPackage(): Promise { const projected = projectSeedRows(); assertMeasuredState(projected); @@ -459,7 +586,8 @@ function render(ctx: { const parentUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('parentCode')); const nameUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('name')); const descUpdates = p3.plan.updates.filter((u) => u.changedFields.includes('description')); - const documentChildren = projected.filter((r) => r.parentCode !== null).length; + const clearCount = parentUpdates.filter((u) => u.row.parentCode === null).length; + const rewireCount = parentUpdates.length - clearCount; out.push( '# Chart of accounts — Change A (structural) — §7 gate package', @@ -486,13 +614,14 @@ function render(ctx: { 'for 5020?". Put the two in one package and neither question gets asked properly.', '', 'The exclusion is **mechanical, not editorial**: Change A runs under the field scope', - `\`PERSISTED_FIELDS minus scheduleELine\` = \`${CHANGE_A_FIELDS.join(', ')}\`. There is no`, + `\`PERSISTED_FIELDS minus scheduleELine and taxDeductible\` = \`${CHANGE_A_FIELDS.join(', ')}\`. There is no`, 'per-code deny-list to keep in sync — the column simply is not in the SET list the seed', 'can build. The five rows surface below under "deferred", as still-drifted, in this same', 'artifact, so a reader cannot mistake Change A for "the chart is now in sync".', '', - '`type`, `subtype` and `taxDeductible` **are** inside Change A\'s scope and emit no', - 'statement, because they are measured identical across all 80 common codes', + '`type` and `subtype` are inside Change A\'s scope and emit no statement, and', + '`taxDeductible` is outside it by class (the canonical tax-semantic column, §6) — it', + 'would emit none either way, because all three are measured identical across all 80 common codes', `(\`taxDeductible\` per-field digest \`${MEASURED_IDENTICAL_FIELDS[0].digest}\` on both sides).`, '**There is no `tax_deductible` defect.** The earlier package', '`scripts/coa-apply-gate-package.ts` (#214) asserted four `tax_deductible` UPDATEs on', @@ -567,14 +696,18 @@ function render(ctx: { 'and not merely the plan.', '', `Breakdown: ${parentUpdates.length} \`parent_code\`, ${nameUpdates.length} \`name\`, ` + - `${descUpdates.length} \`description\`. See §10 on why the description count here is a` + - ' lower bound.', - '', - `**Where the 37 rewirings are.** The document gives ${documentChildren} accounts a parent.`, - `${parentUpdates.length} of them already exist and are rewired by an UPDATE here;`, - `${documentChildren - parentUpdates.length} are among the 25 accounts phases 1-2 INSERT, and`, - 'arrive with their parent already set, so they need no UPDATE. 37 total either way — the', - 'difference is which statement carries it.', + `${descUpdates.length} \`description\` (exactly ` + + `${MEASURED_DESCRIPTION_DRIFT_CODES.map((c) => `\`${c}\``).join(' and ')}, measured; ` + + 'those rows are also in the parent/name counts, so the total is unchanged).', + '', + `**The ${parentUpdates.length} parent changes.** Production's per-row \`parent_code\` was measured`, + `(read-only), not assumed: ${rewireCount} existing rows are REWIRED to a header phases 1-2 insert,`, + `and ${clearCount} are CLEARED (production holds a parent, the document says none), 0 rewired`, + 'to an existing code. The clears matter: without them 5010 and 5050 stay parented to 5000', + 'Advertising, a POSTING account, violating CHART-OF-ACCOUNTS §1.7 (a parent must be a', + 'header; depth exactly 1) while the tree looks migrated. An earlier revision of this', + 'package assumed every existing parent was NULL, so desired-NULL rows diffed empty and only', + '34 of 71 were compiled.', ], p3.statements); out.push( @@ -648,23 +781,29 @@ function render(ctx: { h('§7 — rollback recipe'); out.push( - '### The UPDATEs (phase 3) — a true rollback exists', + `### The UPDATEs (phase 3) — a true rollback exists, one statement per row`, + '', + `All ${p3.plan.updates.length}, not a template. Each restores **exactly the columns that row's SET list`, + 'names** — restoring a column the apply never wrote would itself be a write — plus', + '`updated_at`.', + '', + `Every \`parent_code\` below is the MEASURED prior value, and \`name\` for`, + `${MEASURED_NAME_DRIFT.code} is the measured prior \`${MEASURED_NAME_DRIFT.from}\`. So these are runnable as`, + 'soon as §4 query 1 supplies the row `id` and `updated_at`.', '', - 'For each row phase 3 wrote, restoring every column it set **and `updated_at`**:', + '`description` is the exception, stated rather than papered over: its prior value was', + 'never captured — the drift was measured as a difference, not as a before-value — so the', + `two rows that write it (${MEASURED_DESCRIPTION_DRIFT_CODES.map((c) => `\`${c}\``).join(' and ')}) keep a \`\``, + 'placeholder and cannot be rolled back until query 1 has run. A rollback that restored a', + 'plausible-looking prior description would be a second wrong write dressed as a recovery.', '', '```sql', - 'UPDATE chart_of_accounts', - " SET name = ,", - " description = ,", - " parent_code = , -- NULL restores to NULL", - " updated_at = ''", - " WHERE id = '' AND tenant_id IS NULL;", + ...p3.plan.updates.flatMap((u) => rollbackStatement(u)), '```', '', '`updated_at` is restored deliberately. A rollback that restores the value but not the', 'timestamp leaves the row looking recently and deliberately set, which corrupts the audit', - 'trail as surely as the wrong value did. Restore only the columns that row\'s SET list', - 'actually names — restoring a column the apply never wrote would itself be a write.', + 'trail as surely as the wrong value did.', '', '### The INSERTs (phases 1-2) — there is NO true rollback. Stated, not papered over.', '', @@ -774,21 +913,20 @@ function render(ctx: { '- **The §4 snapshot has not been taken.** This package contains no claim about what', ' production currently holds beyond the measured aggregates below. Every "current value"', ' question is open until it is run.', - '- **`description` drift was never isolated to a code list.** It was measured only in', - ` aggregate, as part of "71 of 80 common codes drifted". So this package compiles`, - ` ${descUpdates.length} description UPDATE(s) and the live apply will emit more. The`, - ' statement count here is a **lower bound**, and the §2 section says so where it counts', - ' them. What is *not* uncertain: the value each description UPDATE binds comes from the', - ' authoritative projection, so the digest in §5 is exact even though the statement list', - ' is not.', - '- **`parent_code` starting values were not captured per row.** The fixture takes all 80 as', - ' NULL. Where a row holds a STRAY parent the document clears (5050 and 9010 were cited as', - ` examples), the live apply emits an UPDATE this package does not compile. So the`, - ` ${parentUpdates.length} compiled \`parent_code\` statements are the rows the document gives`, - ' a header, and the clears are additional.', - '- **The 9 measured-identical codes are not identified**, so the fixture\'s drifted-row count', - ' is a lower bound on the measured 71. The three gaps above are the whole of the', - ' difference; the apply recomputes from the live table and is unaffected by any of them.', + '- **`description` drift is measured exactly, not bounded.** Production was compared to the', + ` document on all 80 common codes: ${MEASURED_DESCRIPTION_DRIFT_CODES.map((c) => `\`${c}\``).join(' and ')} drift,`, + ' the other 78 match byte-for-byte. Both are already phase-3 updates, so this package', + ` compiles ${descUpdates.length} description write(s) and the statement count is unchanged.`, + ' Production\'s prior description for those two is not in this package; §4 records it.', + '- **Prior `description` values exist only in the snapshot.** The description drift was', + ' measured as a difference, not as a before-value, so this package holds what those two', + ' rows will BECOME and not what they are. The §7 rollback for them is therefore unusable', + ' until §4 query 1 has run. Every other rollback value here is measured.', + `- **The 9 measured-identical codes are not identified by code.** 80 common codes minus the`, + ` ${p3.plan.updates.length} compiled here leaves 9, which agrees with the measurement — but the measurement`, + ' reported a count, not a list, so the identity of those 9 is inferred from this package', + ' rather than confirmed against production. §4 query 1 settles it; the apply recomputes', + ' from the live table and is unaffected either way.', '- **Tenant-scoped overrides were not checked.** Nothing here can write one — every', ' statement is `tenant_id IS NULL` — but whether a tenant holds an override of any of', ' these 105 codes, which would then diverge from the global chart, is unknown. Query 3 in', diff --git a/server/__tests__/coa-change-a-gate-package.test.ts b/server/__tests__/coa-change-a-gate-package.test.ts index af346e1..156ce29 100644 --- a/server/__tests__/coa-change-a-gate-package.test.ts +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -13,6 +13,8 @@ import { MISSING_CODES, CHANGE_B_SCHEDULE_E, CHANGE_A_FIELDS, + MEASURED_PARENT_CODES, + MEASURED_PARENT_CLEARS, type CompiledStatement, type ChangeAPackage, } from '../../scripts/coa-change-a-gate-package'; @@ -123,7 +125,7 @@ describe('G3 — the package runs with no database and no credential', () => { expect(pkg.markdown, `missing section: ${heading}`).toContain(heading); } // §10 must name the three gaps that bound what the package can show. - expect(pkg.markdown).toContain('never isolated to a code list'); + expect(pkg.markdown).toContain('Prior `description` values exist only in the snapshot'); expect(pkg.markdown).toContain('Tenant-scoped overrides were not checked'); expect(pkg.markdown).toContain('snapshot has not been taken'); // §7 must not pretend the inserts are reversible. @@ -139,6 +141,75 @@ describe('G3 — the package runs with no database and no credential', () => { }); }); +/** + * The invariant whose absence hid the defect. + * + * An earlier revision's fixture assumed every existing row held `parent_code = NULL`. That + * assumption AGREED with the desired state on all 37 rows the document parentless, so those + * rows diffed to nothing and only 34 of 71 parent changes were compiled. Nothing in the + * suite noticed, because nothing asked what tree the compiled statements actually leave + * behind — they were checked one at a time, never as a whole. + * + * These two tests ask that question. The first pins the clears individually; the second + * reconstructs the post-Change-A hierarchy and holds it against CHART-OF-ACCOUNTS §1.7. + */ +describe('§1.7 — the hierarchy the compiled statements actually leave behind', () => { + const updates = () => pkg.statements.filter((s) => s.kind === 'update'); + + /** The value an UPDATE binds to `parent_code`: position in the SET list is the param index. */ + function boundParent(s: CompiledStatement): string | null { + const i = s.setColumns.indexOf('parent_code'); + expect(i, `${s.code} does not set parent_code`).toBeGreaterThanOrEqual(0); + // 4110 sets description first, so index-by-position is required, not params[0]. + return JSON.parse(s.params[i]) as string | null; + } + + it('emits an UPDATE binding parent_code to NULL for every one of the measured clears', () => { + // The 37 rows where production holds a parent and the document says none. This is the + // exact set that diffed empty under the all-NULL fixture. + expect(MEASURED_PARENT_CLEARS).toHaveLength(37); + const byCode = new Map(updates().map((s) => [s.code, s])); + for (const code of MEASURED_PARENT_CLEARS) { + const s = byCode.get(code); + expect(s, `no UPDATE compiled for measured clear ${code}`).toBeDefined(); + expect(s!.setColumns, `${code} must set parent_code`).toContain('parent_code'); + expect(boundParent(s!), `${code} must be cleared to NULL, not re-parented`).toBeNull(); + } + const cleared = updates().filter((s) => s.setColumns.includes('parent_code') && boundParent(s) === null); + expect(cleared.map((s) => s.code).sort()).toEqual([...MEASURED_PARENT_CLEARS].sort()); + }); + + it('leaves a tree where every parent is a header at depth exactly 1, equal to the document', () => { + // Reconstruct: production's measured parents, overlaid with what the UPDATEs bind, plus + // the parents the 25 INSERTed rows arrive with. (Those 25 come from the plan rows rather + // than from parsed INSERT params — the defect was in the UPDATE path, and an INSERT + // writes every column by definition, so there is nothing for it to omit.) + const after = new Map(MEASURED_PARENT_CODES); + for (const s of updates()) { + if (s.setColumns.includes('parent_code')) after.set(s.code, boundParent(s)); + } + for (const row of [...pkg.plans.phase1.inserts, ...pkg.plans.phase2.inserts]) { + after.set(row.code, row.parentCode); + } + expect(after.size).toBe(105); + + // §1.7 first, and by itself, so a violation is reported as a violation rather than as a + // diff against the projection. + const headers = new Set(HEADER_CODES); + for (const [code, parent] of after) { + if (parent === null) continue; + expect(headers, `${code} is parented to ${parent}, which is not a header (§1.7)`) + .toContain(parent); + expect(after.get(parent) ?? null, `${code} -> ${parent} is depth 2; §1.7 allows exactly 1`) + .toBeNull(); + } + + // And the whole tree equals the document's. Nothing half-migrated, nothing extra. + const document = new Map(projectSeedRows().map((r) => [r.code, r.parentCode])); + expect([...after.entries()].sort()).toEqual([...document.entries()].sort()); + }); +}); + describe('G4 — Change B is provably excluded', () => { it('emits no statement that sets schedule_e_line on any of the five Change B codes', () => { const touching: CompiledStatement[] = pkg.statements.filter((s) => @@ -172,11 +243,44 @@ describe('G4 — Change B is provably excluded', () => { it('excludes the form-line column by subtracting it from PERSISTED_FIELDS, not by a code list', () => { // The mechanism matters: a per-code deny-list has to be kept in sync with the drift, // and a code that drifts later is silently included. - expect(CHANGE_A_FIELDS).toEqual(PERSISTED_FIELDS.filter((f) => f !== 'scheduleELine')); + expect(CHANGE_A_FIELDS).toEqual( + PERSISTED_FIELDS.filter((f) => f !== 'scheduleELine' && f !== 'taxDeductible'), + ); expect(CHANGE_A_FIELDS).not.toContain('scheduleELine'); + expect(CHANGE_A_FIELDS).not.toContain('taxDeductible'); expect(pkg.plans.phase3.scope?.fields).toEqual(CHANGE_A_FIELDS); }); + it('keeps tax_deductible out of the phase-3 scope and out of every UPDATE SET list (§6)', () => { + // Two halves, and only the first can fail on today's data. `taxDeductible` is measured + // IDENTICAL across all 80 common codes, so putting it back in scope emits zero SET + // entries and the SET-list half alone would pass — a decoration. Mutation-tested: the + // scope assertion is what fires. The SET-list half is kept because it is the invariant + // that matters the day the document's deductibility changes before the apply runs. + expect(pkg.plans.phase3.scope?.fields).not.toContain('taxDeductible'); + + // UPDATE SET lists only. An INSERT column list comes from the table definition, so an + // omitted column still appears there (rendered `default`) and asserting on it is vacuous. + const updates = pkg.statements.filter((s) => s.kind === 'update'); + expect(updates.length).toBe(71); + for (const s of updates) { + expect(s.setColumns, `${s.code} must not set tax_deductible`).not.toContain('tax_deductible'); + } + }); + + it('writes description on exactly the two measured-drift codes, 4000 and 4110, and no other', () => { + const withDesc = pkg.statements + .filter((s) => s.kind === 'update' && s.setColumns.includes('description')) + .map((s) => s.code) + .sort(); + expect(withDesc).toEqual(['4000', '4110']); + // Already among the 71 phase-3 updates, so the statement count does not move. + expect(pkg.statements.filter((s) => s.kind === 'update').length).toBe(71); + expect(pkg.statements.filter((s) => s.kind === 'insert').length).toBe(25); + expect(pkg.markdown).not.toContain('lower bound on the measured 71 description'); + expect(pkg.markdown).toContain('`description` drift is measured exactly, not bounded'); + }); + it('inserting a new account with its form line is not a Change B write', () => { // An INSERT cannot CHANGE a mapping — the row had none. The guard above is per-code for // exactly this reason, and this pins the reasoning so a later reader does not "fix" it From 8677b2da2038d1c8569840d1e5e852917e317d94 Mon Sep 17 00:00:00 2001 From: NB <208086304+chitcommit@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:09:33 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(coa):=20sha256=20the=20=C2=A75=20digest?= =?UTF-8?q?,=20on=20both=20sides=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL blocked this PR high on `js/weak-cryptographic-algorithm` at `coa-change-a-gate-package.ts:452` — `createHash('md5')`, truncated to 12 hex. It matters more than a generic weak-hash flag. This digest is §5's authority — "reconcile AFTER, by digest, not by reading the list" — so it is the one thing standing between a partially-applied chart and a clean bill of health. A collision-broken hash is the wrong primitive for that job whatever the odds of an accidental collision over ~100 rows. The fix is two-sided and had to be. The §5 statement the package emits for the operator to run used `substr(md5(string_agg(...)), 1, 12)`. Changing the TS side alone would leave the two comparing different things, turning a correct apply into an apparent failure — the trap §5 already warns about for the Change-A vs Change-B digests. Both now use sha256: TS createHash('sha256').update(body).digest('hex').slice(0, 12) SQL substr(encode(sha256(convert_to(string_agg(...), 'UTF8')), 'hex'), 1, 12) CROSS-IMPLEMENTATION AGREEMENT IS NOW PROVEN, which it never was in either algorithm. Over the same three live rows — chosen to cover a null description, a null parent, and both boolean values — `digestRows()` and the emitted Postgres statement both return `16be21bd0c2f`. §5 compares these two outputs, so their equivalence is the assumption the whole reconciliation rests on, and it had been inherited rather than tested. Expected digests regenerate accordingly: Change A f7cd982cbb00 -> 5eba5626a9eb Change A + B 2db91a8332bb -> cd0887961daf The generated doc changes on exactly 8 lines: the four-line SQL form and the two digest table entries. Nothing else moved. NOT TOUCHED: `MEASURED_IDENTICAL_FIELDS[0].digest = '7b44da66cdfb'`. It is a hand-recorded production measurement with no reproducer in the repo — I could not re-derive it with md5 by any obvious formula, so replacing it with a sha256 value computed by a formula of my own would substitute my measurement for one I cannot show is equivalent. It is prose-only (interpolated into §1) and not compared against anything, so it does not affect the gate. That it cannot be reproduced is its own small finding, noted on the PR. Verified: generator output byte-identical to the committed doc; 24/24 guards; tsc clean; no `md5` call left in the script and none in the emitted document. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- docs/gates/coa-change-a-2026-10-21.md | 8 ++++---- scripts/coa-change-a-gate-package.ts | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/gates/coa-change-a-2026-10-21.md b/docs/gates/coa-change-a-2026-10-21.md index d4f62db..1178cdb 100644 --- a/docs/gates/coa-change-a-2026-10-21.md +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -1832,18 +1832,18 @@ so the digest and the plan cannot disagree about what "equal" means: ```sql SELECT count(*) AS rows, - substr(md5(string_agg( + substr(encode(sha256(convert_to(string_agg( code || ':' || coalesce(name,'') || ':' || coalesce(description,'') || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'') - || ':' || tax_deductible::text, ',' ORDER BY code)), 1, 12) AS digest + || ':' || tax_deductible::text, ',' ORDER BY code), 'UTF8')), 'hex'), 1, 12) AS digest FROM chart_of_accounts WHERE tenant_id IS NULL; ``` | after | rows | digest | |---|---|---| -| **Change A** (what this package delivers) | 105 | `f7cd982cbb00` | -| Change A **+ Change B** (the document in full) | 105 | `2db91a8332bb` | +| **Change A** (what this package delivers) | 105 | `5eba5626a9eb` | +| Change A **+ Change B** (the document in full) | 105 | `cd0887961daf` | **Check against the first row, not the second.** The second is the projection's own digest and it is the post-Change-B state: after a fully successful Change A it will NOT diff --git a/scripts/coa-change-a-gate-package.ts b/scripts/coa-change-a-gate-package.ts index a88d2bf..58bd79e 100644 --- a/scripts/coa-change-a-gate-package.ts +++ b/scripts/coa-change-a-gate-package.ts @@ -434,7 +434,19 @@ export function normaliseForDigest(value: string | boolean | null): string { return value; } -/** md5 of `code:name:description:parent:schedule_e:tax_deductible` joined by ',' ordered by code. */ +/** + * sha256 of `code:name:description:parent:schedule_e:tax_deductible` joined by ',' + * ordered by code, truncated to 12 hex. + * + * sha256 and not md5: this digest is §5's authority — "reconcile AFTER, by digest, not by + * reading the list" — so it is the one thing standing between a partially-applied chart and + * a clean bill of health. A collision-broken hash is the wrong primitive for that job, and + * CodeQL flags it high (js/weak-cryptographic-algorithm). + * + * The Postgres statement emitted in §5 MUST use the same algorithm. The two are compared + * against each other, so changing one alone turns a correct apply into an apparent failure — + * the trap §5 already warns about for the Change-A vs Change-B digests. + */ export function digestRows(rows: SeedAccountRow[]): string { const body = [...rows] .sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0)) @@ -449,7 +461,7 @@ export function digestRows(rows: SeedAccountRow[]): string { ].join(':'), ) .join(','); - return createHash('md5').update(body).digest('hex').slice(0, 12); + return createHash('sha256').update(body).digest('hex').slice(0, 12); } /** @@ -843,10 +855,10 @@ function render(ctx: { '', '```sql', 'SELECT count(*) AS rows,', - ' substr(md5(string_agg(', + ' substr(encode(sha256(convert_to(string_agg(', " code || ':' || coalesce(name,'') || ':' || coalesce(description,'')", " || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'')", - " || ':' || tax_deductible::text, ',' ORDER BY code)), 1, 12) AS digest", + " || ':' || tax_deductible::text, ',' ORDER BY code), 'UTF8')), 'hex'), 1, 12) AS digest", ' FROM chart_of_accounts', ' WHERE tenant_id IS NULL;', '```', From 6ab069d0549a3976ee96277f209676a6b13c28c8 Mon Sep 17 00:00:00 2001 From: NB Date: Sun, 25 Oct 2026 18:46:32 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(coa):=20make=20the=20=C2=A75=20digest?= =?UTF-8?q?=20injective,=20and=20pin=20it=20to=20an=20external=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separated adversarial review (the first this branch has had) found that the §5 digest cannot do §5's job. Confirmed, then fixed. THE DEFECT digestRows joined fields with ':' and rows with ',' and escaped nothing, so the encoding was not injective: name="Repairs:Interior", description="" -> 9bf5650f06cd name="Repairs", description="Interior:" -> 9bf5650f06cd Two materially different charts, one digest. §5 exists so a partially-applied chart cannot reconcile as clean; a digest two states can share does not provide that. Measured against production the same day: 14 of 80 global rows already carry a ':' or a ',' in name or description. Reachable, not theoretical. WHY THE EXISTING EVIDENCE MISSED IT I had reported "cross-implementation agreement proven" — TS createHash and the emitted Postgres sha256 both returned 16be21bd0c2f over three live rows. They did. Both implement the SAME ambiguous encoding, so they agreed. Agreement between two implementations proves they compute the same function; it says nothing about whether that function separates distinct inputs. The 24 guards could not have caught it either: every digest assertion was expect(markdown).toContain(digestRows(x)) — the expectation recomputed with the function under test, and zero 12-hex literals in the file. A wrong encoder made the document and the expectation wrong together, and the suite stayed green. THE FIX Both sides now length-prefix each field as `:`, which is self-delimiting, so no separator is needed and no value can be mistaken for a boundary. Arity is fixed at six. TS: `${Buffer.byteLength(s, 'utf8')}:${s}` SQL: octet_length(coalesce(col,''))::text || ':' || coalesce(col,'') Bytes, not characters: Postgres length() counts characters and JS .length counts UTF-16 code units, and they disagree on any non-BMP character. Every field is coalesced on the SQL side — a NULL anywhere in the concatenation makes the whole row NULL, and string_agg would then drop that row silently. ORDER BY code COLLATE "C" so the row order cannot move with collation. THE EVIDENCE THAT CAN FAIL Three new tests, all able to fail, unlike the ones they sit beside: 1. digestRows(ORACLE_ROWS) === 'bd8c7c8a2851' — a LITERAL, not a recomputation. Produced by executing the §5 statement this generator emits against Neon solitary-rice-14149088 over exactly those rows as a VALUES list. The rows are adversarial on purpose: ':' in a name, ',' in a name and a description, multi-byte UTF-8, a NULL boolean, an empty string. 2. Injectivity across a delimiter moving between fields — the pair that collided above must now differ. 3. The emitted SQL uses octet_length, not length, and orders under COLLATE "C". Mutation-tested: changing the TS encoder from byte length to character length FAILS test 1. That is the exact divergence that would otherwise ship silently — every ASCII fixture passes and only the emoji row diverges. Digests move with the encoding: Change A f27f6b23b1ee, Change A+B 6415a039fd05. Document regenerated and byte-identical on a second run. tsc clean, 755/755. STILL OPEN from the same review, not addressed here: - §2 reconciles plan against compiled SQL by statement COUNT, never by column: setColumns() parses the SET list by regex with a `?? ''` fail-open and is used for display and rollback, but nothing asserts it equals the plan's changedFields. A dropped column keeps the count intact. - The recording client returns rowCount 0 for every UPDATE, so no UPDATE is proven to match a row at apply time — #214's zero-row-success, unaddressed. - The only recovery for the 25 INSERTs is an un-emitted hand-written write. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- docs/gates/coa-change-a-2026-10-21.md | 20 ++++-- scripts/coa-change-a-gate-package.ts | 67 ++++++++++++++----- .../coa-change-a-gate-package.test.ts | 51 ++++++++++++++ 3 files changed, 115 insertions(+), 23 deletions(-) diff --git a/docs/gates/coa-change-a-2026-10-21.md b/docs/gates/coa-change-a-2026-10-21.md index 1178cdb..5c1e8ea 100644 --- a/docs/gates/coa-change-a-2026-10-21.md +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -1828,22 +1828,30 @@ Eyeballing a list is how the original drift survived: a human reading eighty row reliably confirms what they expect to see. Same normalisation as the derivation — `NULL` → `''`, boolean → `'true'`/`'false'` — -so the digest and the plan cannot disagree about what "equal" means: +so the digest and the plan cannot disagree about what "equal" means. Each field is +length-prefixed `:` so the encoding is injective: without that, a `:` or a +`,` inside a name or description shifts a field boundary and two different charts hash +alike. Fourteen of the eighty live rows carry one of those characters today, so this is +the difference between §5 working and §5 only appearing to: ```sql SELECT count(*) AS rows, substr(encode(sha256(convert_to(string_agg( - code || ':' || coalesce(name,'') || ':' || coalesce(description,'') - || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'') - || ':' || tax_deductible::text, ',' ORDER BY code), 'UTF8')), 'hex'), 1, 12) AS digest + octet_length(coalesce(code,''))::text || ':' || coalesce(code,'') + || octet_length(coalesce(name,''))::text || ':' || coalesce(name,'') + || octet_length(coalesce(description,''))::text || ':' || coalesce(description,'') + || octet_length(coalesce(parent_code,''))::text || ':' || coalesce(parent_code,'') + || octet_length(coalesce(schedule_e_line,''))::text || ':' || coalesce(schedule_e_line,'') + || octet_length(coalesce(tax_deductible::text,''))::text || ':' || coalesce(tax_deductible::text,'') + , '' ORDER BY code COLLATE "C"), 'UTF8')), 'hex'), 1, 12) AS digest FROM chart_of_accounts WHERE tenant_id IS NULL; ``` | after | rows | digest | |---|---|---| -| **Change A** (what this package delivers) | 105 | `5eba5626a9eb` | -| Change A **+ Change B** (the document in full) | 105 | `cd0887961daf` | +| **Change A** (what this package delivers) | 105 | `f27f6b23b1ee` | +| Change A **+ Change B** (the document in full) | 105 | `6415a039fd05` | **Check against the first row, not the second.** The second is the projection's own digest and it is the post-Change-B state: after a fully successful Change A it will NOT diff --git a/scripts/coa-change-a-gate-package.ts b/scripts/coa-change-a-gate-package.ts index 58bd79e..cfa9279 100644 --- a/scripts/coa-change-a-gate-package.ts +++ b/scripts/coa-change-a-gate-package.ts @@ -435,32 +435,57 @@ export function normaliseForDigest(value: string | boolean | null): string { } /** - * sha256 of `code:name:description:parent:schedule_e:tax_deductible` joined by ',' - * ordered by code, truncated to 12 hex. + * Length-prefixed field encoding: `:`. + * + * The previous encoding joined fields with `':'` and rows with `','` and escaped nothing, + * so it was NOT injective: `name="Repairs: Interior"` with an empty description produced the + * same bytes as `name="Repairs"` with `description="Interior"`. Measured 2026-10-25 against + * production, 14 of 80 global rows carry a `:` or a `,` in `name` or `description`, so this + * was reachable, not theoretical. A §5 digest that two different chart states can share + * cannot do §5's job — tell a partially-applied chart from a clean one. + * + * Byte length, not `String.length`: Postgres `octet_length()` counts bytes and JavaScript + * `.length` counts UTF-16 code units, and the two disagree on any non-BMP character. The + * emitted SQL in §5 must use `octet_length`, never `length`. + */ +function digestField(value: string | boolean | null): string { + const s = normaliseForDigest(value); + return `${Buffer.byteLength(s, 'utf8')}:${s}`; +} + +/** + * sha256 over the length-prefixed fields `code,name,description,parent,schedule_e, + * tax_deductible` in that order, rows ordered by code, truncated to 12 hex. + * + * Length prefixes make the concatenation self-delimiting, so no separator is needed between + * fields or rows and no value can be mistaken for a boundary. Arity is fixed at six. * * sha256 and not md5: this digest is §5's authority — "reconcile AFTER, by digest, not by * reading the list" — so it is the one thing standing between a partially-applied chart and * a clean bill of health. A collision-broken hash is the wrong primitive for that job, and * CodeQL flags it high (js/weak-cryptographic-algorithm). * - * The Postgres statement emitted in §5 MUST use the same algorithm. The two are compared - * against each other, so changing one alone turns a correct apply into an apparent failure — - * the trap §5 already warns about for the Change-A vs Change-B digests. + * The Postgres statement emitted in §5 MUST produce the same bytes. The two are compared + * against each other, so changing one alone turns a correct apply into an apparent failure. + * Agreement between the two is necessary and NOT sufficient: both can implement the same + * ambiguous encoding and agree, which is exactly how the delimiter collision above survived + * a live three-row cross-check. `digestRows` is pinned against a literal computed by running + * the emitted SQL on the database, and against an injectivity case, in the test suite. */ export function digestRows(rows: SeedAccountRow[]): string { const body = [...rows] .sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0)) .map((r) => [ - r.code, - normaliseForDigest(r.name), - normaliseForDigest(r.description), - normaliseForDigest(r.parentCode), - normaliseForDigest(r.scheduleELine), - normaliseForDigest(r.taxDeductible), - ].join(':'), + digestField(r.code), + digestField(r.name), + digestField(r.description), + digestField(r.parentCode), + digestField(r.scheduleELine), + digestField(r.taxDeductible), + ].join(''), ) - .join(','); + .join(''); return createHash('sha256').update(body).digest('hex').slice(0, 12); } @@ -851,14 +876,22 @@ function render(ctx: { 'reliably confirms what they expect to see.', '', 'Same normalisation as the derivation — `NULL` → `\'\'`, boolean → `\'true\'`/`\'false\'` —', - 'so the digest and the plan cannot disagree about what "equal" means:', + 'so the digest and the plan cannot disagree about what "equal" means. Each field is', + 'length-prefixed `:` so the encoding is injective: without that, a `:` or a', + '`,` inside a name or description shifts a field boundary and two different charts hash', + 'alike. Fourteen of the eighty live rows carry one of those characters today, so this is', + 'the difference between §5 working and §5 only appearing to:', '', '```sql', 'SELECT count(*) AS rows,', ' substr(encode(sha256(convert_to(string_agg(', - " code || ':' || coalesce(name,'') || ':' || coalesce(description,'')", - " || ':' || coalesce(parent_code,'') || ':' || coalesce(schedule_e_line,'')", - " || ':' || tax_deductible::text, ',' ORDER BY code), 'UTF8')), 'hex'), 1, 12) AS digest", + " octet_length(coalesce(code,''))::text || ':' || coalesce(code,'')", + " || octet_length(coalesce(name,''))::text || ':' || coalesce(name,'')", + " || octet_length(coalesce(description,''))::text || ':' || coalesce(description,'')", + " || octet_length(coalesce(parent_code,''))::text || ':' || coalesce(parent_code,'')", + " || octet_length(coalesce(schedule_e_line,''))::text || ':' || coalesce(schedule_e_line,'')", + " || octet_length(coalesce(tax_deductible::text,''))::text || ':' || coalesce(tax_deductible::text,'')", + " , '' ORDER BY code COLLATE \"C\"), 'UTF8')), 'hex'), 1, 12) AS digest", ' FROM chart_of_accounts', ' WHERE tenant_id IS NULL;', '```', diff --git a/server/__tests__/coa-change-a-gate-package.test.ts b/server/__tests__/coa-change-a-gate-package.test.ts index 156ce29..6827243 100644 --- a/server/__tests__/coa-change-a-gate-package.test.ts +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -417,4 +417,55 @@ describe('§5 — the digest uses the derivation\'s own normalisation', () => { expect(pkg.markdown).toContain(digestRows(afterA)); expect(pkg.markdown).toContain('Check against the first row, not the second.'); }); + + // ------------------------------------------------------------------------- + // §5 DIGEST — pinned against an EXTERNAL oracle, and against injectivity. + // + // Every other digest assertion in this file recomputes its expectation with + // `digestRows` itself, so a wrong encoder makes the markdown and the expectation wrong + // together and the test still passes. That is how the delimiter collision below survived + // a live three-row cross-check and 24 green guards. These two tests are the ones that can + // actually fail. + // ------------------------------------------------------------------------- + describe('§5 digest encoding', () => { + // Deliberately adversarial: a ':' in a name, a ',' in both a name and a description, + // multi-byte UTF-8 (where Postgres octet_length and JS String.length disagree), a NULL + // boolean, and an empty string. + const ORACLE_ROWS = [ + { code: '5015', name: 'Repairs: Interior', description: null, parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, + { code: '5016', name: 'Repairs', description: 'Interior', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, + { code: '5017', name: 'Comma, Name', description: 'desc, with comma', parentCode: null, scheduleELine: null, taxDeductible: false }, + { code: '5018', name: 'Ünïcøde ☕ naïve', description: 'multi-byte: ☕', parentCode: '5000', scheduleELine: null, taxDeductible: null }, + { code: '5019', name: '', description: null, parentCode: null, scheduleELine: null, taxDeductible: true }, + ] as unknown as Parameters[0]; + + it('matches a literal produced by running the emitted SQL on the database', () => { + // NOT recomputed with digestRows. This literal was produced by executing the §5 + // statement this generator emits against Neon project solitary-rice-14149088 on + // 2026-10-25, over exactly the rows above supplied as a VALUES list. If the TypeScript + // encoder and the emitted SQL ever diverge, this fails — which is the whole point: + // the two agreeing with each other proves only that they compute the same function. + expect(digestRows(ORACLE_ROWS)).toBe('bd8c7c8a2851'); + }); + + it('is injective across a delimiter moving between fields', () => { + // Under the previous encoding — fields joined by ':' and rows by ',' with no escaping + // — these two rows both produced 9bf5650f06cd. Two materially different charts, one + // digest, and §5 would have called a partially-applied chart clean. Measured against + // production the same day: 14 of 80 live rows carry a ':' or a ',' in name or + // description, so this was reachable, not theoretical. + const a = [{ code: '5015', name: 'Repairs:Interior', description: '', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; + const b = [{ code: '5015', name: 'Repairs', description: 'Interior:', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; + expect(digestRows(a as never)).not.toBe(digestRows(b as never)); + }); + + it('emits SQL that length-prefixes by BYTES, not characters', () => { + // length() counts characters and octet_length() counts bytes; they disagree on any + // non-BMP character, and JS Buffer.byteLength matches the latter. An SQL side built on + // length() would pass every ASCII fixture and diverge on the first emoji. + expect(pkg.markdown).toContain('octet_length(coalesce(name,\'\'))::text'); + expect(pkg.markdown).not.toMatch(/\bstring_agg\([^)]*\blength\(/); + expect(pkg.markdown).toContain('ORDER BY code COLLATE "C"'); + }); + }); }); From 1b22abe41d1769b575c353737d24efadd195c1e9 Mon Sep 17 00:00:00 2001 From: NB Date: Sun, 25 Oct 2026 18:50:23 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(coa):=20=C2=A75=20must=20read=20every?= =?UTF-8?q?=20column=20Change=20A=20writes=20=E2=80=94=20type=20and=20subt?= =?UTF-8?q?ype=20were=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by re-reading the package against finance-operating-defaults rather than by the adversarial reviewer, which did not raise it. Change A WRITES: name, type, subtype, description, parentCode The §5 digest READ: code, name, description, parentCode, schedule_e_line, tax_deductible `type` and `subtype` were written by the change and never read by the reconciliation. §5: "Reconciliation reads every column the change claimed to set." §10: "a column never read is a column never verified." A post-apply drift in either column would have reconciled as clean — the same failure the digest exists to prevent, one layer in. Both now in the digest, both sides, in PERSISTED_FIELDS order: code, name, type, subtype, description, parent_code, schedule_e_line, tax_deductible. `schedule_e_line` and `tax_deductible` stay in the digest although Change A does not write them. That is deliberate: they pin the full row state so Change B's outstanding drift remains visible, and §5 already documents why the Change A row and the Change A+B row differ. EVIDENCE - Oracle re-pinned: digestRows(ORACLE_ROWS) === 'b8e27fa6dc98', a literal produced by running the newly emitted 8-field SQL against Neon solitary-rice-14149088 over the same adversarial VALUES set, now carrying type/subtype including two NULLs. TS and Postgres agree. - New test asserts set equality between CHANGE_A_FIELDS and the fields the digest reads, so adding a field to the change without adding it to the digest fails here rather than silently narrowing §5. - Mutation-tested: dropping `type` from both sides fails BOTH the oracle pin and the coverage test. Digests move again: Change A 0fb83ade80f1, Change A+B 1b77414d2a24. Document regenerated, byte-identical on a second run. tsc clean, 756/756. §6 checked while here and SATISFIED: CHANGE_A_FIELDS is PERSISTED_FIELDS minus scheduleELine and taxDeductible, so the structural change carries no tax semantics. It is correct by subtraction and NOT enforced — a new tax-semantic field added to PERSISTED_FIELDS would join Change A silently. Not fixed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W2qFSSKdbsxo7AS8Buo247 --- docs/gates/coa-change-a-2026-10-21.md | 6 ++-- scripts/coa-change-a-gate-package.ts | 15 +++++++-- .../coa-change-a-gate-package.test.ts | 32 ++++++++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/gates/coa-change-a-2026-10-21.md b/docs/gates/coa-change-a-2026-10-21.md index 5c1e8ea..ba01789 100644 --- a/docs/gates/coa-change-a-2026-10-21.md +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -1839,6 +1839,8 @@ SELECT count(*) AS rows, substr(encode(sha256(convert_to(string_agg( octet_length(coalesce(code,''))::text || ':' || coalesce(code,'') || octet_length(coalesce(name,''))::text || ':' || coalesce(name,'') + || octet_length(coalesce(type,''))::text || ':' || coalesce(type,'') + || octet_length(coalesce(subtype,''))::text || ':' || coalesce(subtype,'') || octet_length(coalesce(description,''))::text || ':' || coalesce(description,'') || octet_length(coalesce(parent_code,''))::text || ':' || coalesce(parent_code,'') || octet_length(coalesce(schedule_e_line,''))::text || ':' || coalesce(schedule_e_line,'') @@ -1850,8 +1852,8 @@ SELECT count(*) AS rows, | after | rows | digest | |---|---|---| -| **Change A** (what this package delivers) | 105 | `f27f6b23b1ee` | -| Change A **+ Change B** (the document in full) | 105 | `6415a039fd05` | +| **Change A** (what this package delivers) | 105 | `0fb83ade80f1` | +| Change A **+ Change B** (the document in full) | 105 | `1b77414d2a24` | **Check against the first row, not the second.** The second is the projection's own digest and it is the post-Change-B state: after a fully successful Change A it will NOT diff --git a/scripts/coa-change-a-gate-package.ts b/scripts/coa-change-a-gate-package.ts index cfa9279..10c3469 100644 --- a/scripts/coa-change-a-gate-package.ts +++ b/scripts/coa-change-a-gate-package.ts @@ -454,8 +454,15 @@ function digestField(value: string | boolean | null): string { } /** - * sha256 over the length-prefixed fields `code,name,description,parent,schedule_e, - * tax_deductible` in that order, rows ordered by code, truncated to 12 hex. + * sha256 over the length-prefixed fields `code,name,type,subtype,description,parent, + * schedule_e,tax_deductible` in that order, rows ordered by code, truncated to 12 hex. + * + * `type` and `subtype` are in the digest because Change A WRITES them (CHANGE_A_FIELDS is + * PERSISTED_FIELDS minus the two tax-semantic fields). §5 requires the reconciliation to + * read every column the change claimed to set; they were omitted, so a post-apply drift in + * either would have reconciled as clean. `schedule_e_line` and `tax_deductible` are read + * though Change A does not write them — deliberately, so Change B's outstanding drift stays + * visible in the digest. * * Length prefixes make the concatenation self-delimiting, so no separator is needed between * fields or rows and no value can be mistaken for a boundary. Arity is fixed at six. @@ -479,6 +486,8 @@ export function digestRows(rows: SeedAccountRow[]): string { [ digestField(r.code), digestField(r.name), + digestField(r.type), + digestField(r.subtype), digestField(r.description), digestField(r.parentCode), digestField(r.scheduleELine), @@ -887,6 +896,8 @@ function render(ctx: { ' substr(encode(sha256(convert_to(string_agg(', " octet_length(coalesce(code,''))::text || ':' || coalesce(code,'')", " || octet_length(coalesce(name,''))::text || ':' || coalesce(name,'')", + " || octet_length(coalesce(type,''))::text || ':' || coalesce(type,'')", + " || octet_length(coalesce(subtype,''))::text || ':' || coalesce(subtype,'')", " || octet_length(coalesce(description,''))::text || ':' || coalesce(description,'')", " || octet_length(coalesce(parent_code,''))::text || ':' || coalesce(parent_code,'')", " || octet_length(coalesce(schedule_e_line,''))::text || ':' || coalesce(schedule_e_line,'')", diff --git a/server/__tests__/coa-change-a-gate-package.test.ts b/server/__tests__/coa-change-a-gate-package.test.ts index 6827243..5ed15a6 100644 --- a/server/__tests__/coa-change-a-gate-package.test.ts +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -432,11 +432,11 @@ describe('§5 — the digest uses the derivation\'s own normalisation', () => { // multi-byte UTF-8 (where Postgres octet_length and JS String.length disagree), a NULL // boolean, and an empty string. const ORACLE_ROWS = [ - { code: '5015', name: 'Repairs: Interior', description: null, parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, - { code: '5016', name: 'Repairs', description: 'Interior', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, - { code: '5017', name: 'Comma, Name', description: 'desc, with comma', parentCode: null, scheduleELine: null, taxDeductible: false }, - { code: '5018', name: 'Ünïcøde ☕ naïve', description: 'multi-byte: ☕', parentCode: '5000', scheduleELine: null, taxDeductible: null }, - { code: '5019', name: '', description: null, parentCode: null, scheduleELine: null, taxDeductible: true }, + { code: '5015', name: 'Repairs: Interior', type: 'expense', subtype: 'operating', description: null, parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, + { code: '5016', name: 'Repairs', type: 'expense', subtype: 'operating', description: 'Interior', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }, + { code: '5017', name: 'Comma, Name', type: 'expense', subtype: null, description: 'desc, with comma', parentCode: null, scheduleELine: null, taxDeductible: false }, + { code: '5018', name: 'Ünïcøde ☕ naïve', type: 'asset', subtype: 'current', description: 'multi-byte: ☕', parentCode: '5000', scheduleELine: null, taxDeductible: null }, + { code: '5019', name: '', type: null, subtype: null, description: null, parentCode: null, scheduleELine: null, taxDeductible: true }, ] as unknown as Parameters[0]; it('matches a literal produced by running the emitted SQL on the database', () => { @@ -445,7 +445,7 @@ describe('§5 — the digest uses the derivation\'s own normalisation', () => { // 2026-10-25, over exactly the rows above supplied as a VALUES list. If the TypeScript // encoder and the emitted SQL ever diverge, this fails — which is the whole point: // the two agreeing with each other proves only that they compute the same function. - expect(digestRows(ORACLE_ROWS)).toBe('bd8c7c8a2851'); + expect(digestRows(ORACLE_ROWS)).toBe('b8e27fa6dc98'); }); it('is injective across a delimiter moving between fields', () => { @@ -454,11 +454,27 @@ describe('§5 — the digest uses the derivation\'s own normalisation', () => { // digest, and §5 would have called a partially-applied chart clean. Measured against // production the same day: 14 of 80 live rows carry a ':' or a ',' in name or // description, so this was reachable, not theoretical. - const a = [{ code: '5015', name: 'Repairs:Interior', description: '', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; - const b = [{ code: '5015', name: 'Repairs', description: 'Interior:', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; + const a = [{ code: '5015', name: 'Repairs:Interior', type: 'expense', subtype: null, description: '', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; + const b = [{ code: '5015', name: 'Repairs', type: 'expense', subtype: null, description: 'Interior:', parentCode: '5000', scheduleELine: 'Line 14', taxDeductible: true }]; expect(digestRows(a as never)).not.toBe(digestRows(b as never)); }); + it('reads every column Change A writes (§5) — type and subtype included', () => { + // §5: "Reconciliation reads every column the change claimed to set." §10: "a column + // never read is a column never verified." Change A writes name, type, subtype, + // description and parentCode; the digest originally read neither type nor subtype, so + // a post-apply drift in either reconciled as clean. This asserts the two sets line up, + // so adding a field to CHANGE_A_FIELDS without adding it to the digest fails here. + const digested = new Set(['name', 'type', 'subtype', 'description', 'parentCode']); + for (const f of CHANGE_A_FIELDS) { + expect(digested.has(f), `CHANGE_A_FIELDS has "${f}" but the §5 digest does not read it`).toBe(true); + } + expect([...digested].every((f) => (CHANGE_A_FIELDS as string[]).includes(f))).toBe(true); + for (const col of ['type', 'subtype']) { + expect(pkg.markdown).toContain(`octet_length(coalesce(${col},''))::text`); + } + }); + it('emits SQL that length-prefixes by BYTES, not characters', () => { // length() counts characters and octet_length() counts bytes; they disagree on any // non-BMP character, and JS Buffer.byteLength matches the latter. An SQL side built on