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..ba01789 --- /dev/null +++ b/docs/gates/coa-change-a-2026-10-21.md @@ -0,0 +1,1941 @@ +# 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 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` 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 +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, 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 +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 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: 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). + +**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** + +```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 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) +``` + +params: +- `$1` = "2590" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "2590" +- `$2` = +- `$3` = "" + +**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 "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` = "Base rent received on unfurnished, year-length leases" +- `$3` = "4090" +- `$4` = +- `$5` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "4095" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5190" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5290" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5390" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5390" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "5490" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "7090" +- `$2` = +- `$3` = "" + +**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) +``` + +params: +- `$1` = "7090" +- `$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` — 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". + +## §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, 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; + +``` + +`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. + +### 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. 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( + 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,'') + || 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 | `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 +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:** 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:** 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 + 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 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 + §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..10c3469 --- /dev/null +++ b/scripts/coa-change-a-gate-package.ts @@ -0,0 +1,1024 @@ +/** + * 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. `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. + */ +export const MEASURED_IDENTICAL_FIELDS: ReadonlyArray<{ field: PersistedField; digest: string }> = [ + { field: 'taxDeductible', digest: '7b44da66cdfb' }, +]; + +/** + * 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 !== 'taxDeductible', +); + +// --------------------------------------------------------------------------- +// 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. + * + * `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 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, + })); +} + +/** + * 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 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`); + + 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`); + + 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); + 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; +} + +/** + * 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,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. + * + * 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 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) => + [ + digestField(r.code), + digestField(r.name), + digestField(r.type), + digestField(r.subtype), + digestField(r.description), + digestField(r.parentCode), + digestField(r.scheduleELine), + digestField(r.taxDeductible), + ].join(''), + ) + .join(''); + return createHash('sha256').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(', '); + +/** 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); + + 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 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', + '', + '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 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` 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', + '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\` (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( + '', + '### 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, 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`.', + '', + '`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', + ...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.', + '', + '### 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. 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(', + " 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,'')", + " || 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) | ${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 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', + ' §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..5ed15a6 --- /dev/null +++ b/server/__tests__/coa-change-a-gate-package.test.ts @@ -0,0 +1,487 @@ +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, + MEASURED_PARENT_CODES, + MEASURED_PARENT_CLEARS, + type CompiledStatement, + type ChangeAPackage, +} 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); + +/** + * 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', () => { + 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('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. + 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); + }); +}); + +/** + * 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) => + 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' && 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 + // 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.'); + }); + + // ------------------------------------------------------------------------- + // §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', 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', () => { + // 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('b8e27fa6dc98'); + }); + + 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', 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 + // 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"'); + }); + }); +});