Skip to content

uid: T-157 title: Entity consolidation β€” Bank Accounts surface + docs marry into Contacts (Subsidiary tab), one sectioned profile model for subsidiaries & individuals status: done area: contacts-accounting created: 2026-07-02 updated: 2026-07-09 related: T-156, T-158, T-048, T-070


T-157 β€” Bank Accounts β†’ Contacts/Subsidiary consolidation (design + phased migration)

Routing: design by Accounting (Diagnostics) (this doc); build owned by Accounting (Infrastructure) β€” it spans the bank-account model, Contacts, and RBAC, all their lane. Diagnostics stands by for data verification per phase.

Why (owner, 2026-07-02)

"The company bank accounts are currently manifested thru the Bank Accounts tab under the Finance tab … I'm wondering if this section of information should be migrate to the subsidiary tab in the Contacts page (including the marrying of Firestore doc) … individuals (under contacts) also has similar structures (personal information, legal information and bank information etc...), so I'm thinking of a complete consolidation (marrying). I understand that it touches a lot of hot path, but I'm confident that we could make the transition smoothly."

The end-state: subsidiaries and individuals are peers in one entity model — sectioned profiles (identity / legal / bank) — with the Finance→Bank Accounts admin surface retired into Contacts → Subsidiary, and eventually the Firestore docs re-homed ("married") to live with their entity.

Current-state inventory (full sweep, 2026-07-02)

Topology: company accounts = bankAccount/{SUB-BANK-TYPE} in each subsidiary's DB (real docs: tebs-erl, 6 accounts). Individuals = aote-system/individuals/{id} with sectioned legal/basic/system (T-048), where legal.bankAccounts: PayeeBankAccountEntry[] is the bank section, write-through-synced to tebs-epl/payees/{abbr} (WOPC/IR56M read the payee side). Subsidiary profile = {db}/_metadata/config, edited by the Subsidiary tab (T-070) as a flat form β€” not yet sectioned.

The company-account contract surface (all consumers): - Admin CRUD: /api/admin/bank-accounts* + components/admin/BankAccountsApp.tsx (grouped table, closure workflow w/ balance preview + 3-step confirm). - Resolvers: lib/erlDirectory.ts + .server.ts (listBanks/listAccounts/ resolveBankAccountIdentifier(Server)/lookupAccountServer/listAllBankAccountsServer), /api/bank-accounts/[id], /api/accounting/bank-accounts (nickname-aware, T-156). - Lifecycle: lib/bankAccountLifecycle.server.ts β€” closure computes the leftover ledger balance from transactions (transaction.transactional.bankAccountId == id) and auto-posts a settlement to GL 1120; writes updateLog audit subcollections. - Displays: T-156's propagation matrix (tx table/filters, register XLSX, Pay-To, drawers, dropdowns) + invoice issuance guard (invoices/send.ts hard-blocks sending when the payTo account is unresolvable/closed).

The stable-ID dependency (the invariant): the doc ID (ERL-OCBC-S) is the foreign key persisted in β‰₯5 stores β€” transactions (bankAccountId; also derives subsidiaryId), invoices (payTo/legacy paidTo), COA (linkedBankAccount, with reverse where('linkedBankAccount','==',id) queries), bank statements (accountId, baked into deterministic statement IDs), closure references (AUTO-BANK-CLOSURE:{id}:{date}), audit-log entity paths. No display name is persisted anywhere (verified in T-156).

Known wobble to fix on the way (from the same sweep): - Prefix→database map duplicated inline in ~7-10 files; id.split('-')[0] subsidiary extraction repeated ~7×; BANK_ACCOUNT_IDS_BY_SUBSIDIARY hardcodes the ERL id list; ERL-AWX-{currency} builder in 2-3 places; OCBC provider acct#→id map hardcoded (I-030/T-158 cover the payee-block copies). - RBAC divergence: bank-accounts page = finance roles + bank:accounts:read@erl badge + EPL requirement; contacts page = admin roles + contacts badges. A finance-only user can see Bank Accounts but not Contacts today, and vice versa. - Firestore-rules asymmetry: only tebs-epl has an explicit bankAccount rules match; tebs-erl/tebs-mel fall to the admin-only catch-all (client-SDK reads of ERL accounts work only for admins; server paths bypass via Admin SDK).

Design principles

  1. bankAccountId is immutable, forever. Every phase preserves the exact doc ID as the join key. Re-homing moves documents, never identities.
  2. Route through one module. All prefix→DB/collection resolution goes through a single bankAccountRouting module before any doc moves — flipping the home later = one file.
  3. UI first, data last. Users get the consolidated experience early (low risk); the Firestore "marrying" happens behind a dual-read shim once the UI no longer cares where docs live.
  4. Each phase independently shippable + reversible. No phase depends on a later one; every phase has a verification gate (Diagnostics reconciles data before/after).
  5. One section model, two schemas honored. Company accounts keep BankAccountRecord (status/lifecycle/closure β€” load-bearing); individuals keep PayeeBankAccountEntry[]. The section UI is shared; the storage schemas are mapped, not force-merged (a company account is not a payee entry: it has lifecycle, closure balances, provider links).

Per-phase exit gate β€” the NO-STALE-READER guarantee (owner requirement)

Owner (2026-07-03, verbatim): "make sure that after the re-write, nowhere on the web app would still be reading the old path and causing disruption in the operation."

Every phase that moves or replaces a path/surface/collection ends with ALL three, and the phase is not done until they pass:

  1. Static stale-reader sweep β€” repo-wide search for the retired path (old collection paths, route strings, map literals, endpoint URLs), with the result table recorded in this task (the T-156 propagation matrix is the template; Phase 0 closed with exactly this β€” zero inline map literals left).
  2. Runtime tripwire on the legacy path β€” wherever a dual-read/fallback shim exists (Phase 3 doc re-home; the provider-map fallbacks from Phase 0), the legacy branch LOGS a loud, greppable marker ([T-157-legacy-read]) every time it fires. The legacy path is retired only after a soak window with zero observed hits β€” proof by observation, not assumption.
  3. Diagnostics reconciliation β€” the data gate already specified per phase (doc-for-doc diffs, closure/lifecycle regression, invoice-send guard).

Rationale: a grep proves no code names the old path; the tripwire proves no runtime behavior still depends on it (caches, persisted client state, stale localStorage nav, in-flight documents). Both are required because past incidents (I-023's stale seqNo dedup, I-030's drifted payee copies) were runtime-visible while looking clean in code review.

Phased plan

Phase 0 — routing + plumbing consolidation (foundation, zero behavior change). Extract lib/accounting/bankAccountRouting.ts: one subsidiary→DB map, one resolveBankAccountDbRoute(id), one extractSubsidiary; replace the ~10 inline copies. Fold the provider maps onto the registry doc (providerAccountNumber for OCBC's 757699831/757699051, populate swiftCode) so getOCBCBankAccountId and the delete-path ID lists resolve by query instead of literals. (T-158 ships the payee-block resolver alongside; it is independently urgent.)

Phase 1 — Subsidiary tab becomes a sectioned profile (UI unification). Rebuild SubsidiaryContent in the T-048 drawer pattern: sections Company identity (names/address/contact from _metadata/config), Legal & filings (BR#, employer file no.), Bank accounts. The bank section embeds the existing account CRUD extracted from BankAccountsApp into a reusable CompanyBankAccountsSection (grouped list, nickname + enable checkbox from T-156, status/closure workflow intact), reading/writing through the existing admin APIs. Finance→Bank Accounts keeps working (same component, two mounts). RBAC: extend the contacts page rule so bank:accounts:read@erl holders can reach the Subsidiary tab's bank section (or surface-level gate per section); API gates unchanged.

Phase 2 β€” retire the Finance surface. Once the Contacts mount is proven: remove bank-accounts from AppShell.DEFAULT_TAB_ORDER, redirect /admin/bank-accounts β†’ /contacts?tab=subsidiary, retire/repoint the bank-accounts page rule, handle users' persisted sidebar-tab-order localStorage reconciliation.

Phase 3 β€” the Firestore marrying (docs re-home). Target home (owner+Infra to ratify): aote-system/subsidiaries/{subId}/bankAccounts/{SAME-DOC-ID} β€” beside individuals/organizations, making subsidiaries first-class entities. Mechanics: (a) author aote-system rules for the new path reconciling the bank badge with contacts gates; (b) backfill copy incl. updateLog subcollections; (c) flip bankAccountRouting to dual-read (new home first, legacy fallback) β€” writes go new-home-only; (d) Diagnostics reconciliation gate (doc-for-doc + field-for-field diff, closure/lifecycle regression: closure preview + settlement txn on a test account, invoice-send guard, statement-id determinism); (e) tombstone legacy docs (read-only marker), retire fallback after a soak window. Closure lifecycle keeps posting to the tebs-erl ledger regardless of doc home (it keys off bankAccountId, not doc path).

Phase 4 (optional, later) β€” organizations parity. Organization docs gain the same bank section shape if/when client-company banking info is wanted; out of scope now.

Riskiest couplings (carried from the sweep β€” each needs a phase-gate test)

  1. Stable-ID foreign keys in 5+ stores (transactions/invoices/COA/statements/ closure refs) β€” never rekey; Phase 3 keeps IDs byte-identical.
  2. Prefix→DB routing ×10 — solved by Phase 0 before anything moves.
  3. Closure lifecycle ↔ ERL ledger coupling β€” regression-test closure preview + settlement on every phase that touches storage or APIs.
  4. Company vs individual schema mismatch β€” mapped at the section-UI layer, not merged in storage (principle 5).
  5. Rules asymmetry β€” Phase 3(a) authors explicit rules in the new home; interim phases rely on Admin-SDK server APIs exactly as today.
  6. RBAC gate divergence β€” Phase 1 defines the union gate deliberately (decision for Infra + owner below).
  7. Invoice issuance guard + XLSX synchronous resolution β€” dual-read shim keeps /api/bank-accounts/[id] + the send.ts inline lookup resolving throughout.
  8. Nav/localStorage tab-order β€” Phase 2 reconciliation logic already exists in AppShell (sidebar-tab-order merge); reuse it.

Decisions needed before build (Infra proposes, owner ratifies)

  • D1. Final doc home: aote-system subsidiaries subcollection (recommended: entity-adjacent, one rules surface) vs staying per-subsidiary-DB with an entity backlink (cheaper, but the "marrying" stays unconsummated).
  • D2. RBAC union: who sees the Subsidiary bank section β€” extend contacts with the bank badge, or per-section gating inside the page?
  • D3. /api/accounting/bank-accounts's requireAuth({subsidiaries:['epl']}) oddity β€” correct to erl (or entity-based) during Phase 0 or leave until 3?
  • D4. Retirement timing of Financeβ†’Bank Accounts (immediately after Phase 1 soak vs alongside Phase 3).

Decision log

2026-07-02 β€” design written + routed to Accounting (Infrastructure)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; board checked by scope β€” no existing task covers this consolidation (T-048 = individuals model, T-070 = current flat Subsidiary tab, T-156 = display labels only). Opened as the owner's "Issue 2".
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Proposed by: the owner (full consolidation incl. Firestore marrying; verbatim above). Design approach: Diagnostics (this doc), from a two-agent very-thorough inventory sweep (Contacts architecture / individuals bank model / full company bankAccount contract surface / stable-ID dependency map / nav+RBAC+rules).
  • Why phased-UI-first: the owner is confident a smooth transition is possible; the inventory shows the risk is concentrated in the doc-ID joins and the routing duplication β€” so the design front-loads routing consolidation (Phase 0), delivers the owner-visible unification early (Phase 1-2), and gates the actual data marrying (Phase 3) behind a dual-read shim + reconciliation, keeping every step reversible.
  • Build owner: Accounting (Infrastructure) β€” bank-account model + Contacts + RBAC are their lane (ownership map, 2026-07-01). Diagnostics provides per-phase data-reconciliation gates (as in I-023/T-156).

2026-07-03 β€” renumbered T-153 β†’ T-157

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; renumber-on-merge, scope unchanged.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Why: T-153 was taken on main by a parallel agent (invoice per-line discount rendering). This design doc moved whole-identity β†’ T-157; the build remains routed to Accounting (Infrastructure).

2026-07-03 β€” Phase 0 SHIPPED (routing + plumbing consolidation); build reassigned to Diagnostics

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md. Owner redirected the build to this session ("please properly start the now T-157 from phase 0" β€” an earlier "phase 4 first" was a confirmed misspoke β€” and "proceed regardless of other agent's work as planned"), so the build is no longer waiting on Accounting (Infrastructure); the D1–D4 decisions still stand open for the owner at the relevant phases.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • New module: lib/accounting/bankAccountRouting.ts β€” THE subsidiaryβ†’database map (SUBSIDIARY_DATABASE_MAP, incl. JFT β†’ jefferochan), subsidiaryDatabaseIdFor, subsidiaryCodeFromBankAccountId, databaseIdFromBankAccountId, listSubsidiaryDatabaseIds (client-safe, pure) + bankAccountRouting.server.ts (resolveBankAccountIdByProviderAccountNumber with hit-cache/miss-TTL, listBankAccountIdsByBankServer). Unit-tested (7 cases).
  • 10 inline copies retired (import-alias pattern, minimal diffs): lib/erlDirectory.ts + .server.ts (map + prefixβ†’db fn), pages/api/bank-accounts/[id].ts (map + prefix fn + db fallback), pages/api/invoices/send.ts (inline BANK_DBS), lib/share/fetchSharedDoc.server.ts (map + fetchSubsidiary/fetchBank lookups), the three admin routes (admin/bank-accounts/index.ts, [id].ts, closure-preview.ts β€” map + local extractSubsidiary now delegate, defaults 'ERL'/'' preserved), pages/api/accounting/bank-accounts.ts (2Γ— split('-')[0]), lib/accounting/csvParser.ts. pages/api/subsidiaries/[id].ts keeps its deliberate mel/erl/epl gate but sources db ids from the module (JFT exclusion there is on purpose β€” profile endpoint scope).
  • Deliberate unification (not drift): copies that omitted JFT now include it β€” JFT-* account ids resolve, and the admin list enumerates the jefferochan DB (per-db try/catch tolerates absence). Recorded here so nobody reads the widened map as an accident.
  • Provider maps β†’ registry: OCBC sync's getOCBCBankAccountId is now registry-first (providerAccountNumber on the bankAccount/{id} doc) with the legacy literals as a zero-behavior-change fallback; the OCBC delete-transactions route derives its id list via listBankAccountIdsByBankServer('OCBC') (same fallback pattern). Registry backfill applied (SA, additive, verified): ERL-OCBC-S.providerAccountNumber=757699831, ERL-OCBC-C.providerAccountNumber=757699051, ERL-DSB-S/C.swiftCode=DABORHKHXXX.
  • Deliberately left (recorded): BANK_ACCOUNT_IDS_BY_SUBSIDIARY + BANK_ABBREVIATIONS in bankAccountUtils (sync client-side query lists β€” converting to async registry reads changes call shapes; a later-phase item), dev scripts' literals, and the cosmetic split('-') short-code display in TransactionDetailsDrawer (not routing).
  • Verification: tsc --noEmit clean Β· vitest 599/599 (incl. the new routing tests) Β· repo-wide grep: zero remaining inline tebs-mel/tebs-erl/tebs-epl map literals outside the module.
  • Blast radius (routing): every bank-account db-routing path now flows through one module β€” the precondition for Phase 3's re-homing ("flip the home in one file"). Behavior identical except the recorded JFT unification.

2026-07-03 β€” Phase 1 SHIPPED (sectioned Subsidiary tab + shared bank section)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • What shipped: SubsidiaryContent is now a sectioned entity profile β€” Company identity (names/address/contact) Β· Legal & filings (BR no., IRD employer file) Β· Bank accounts. The bank section mounts the SAME BankAccountsApp used by Finance β†’ Bank Accounts, via new { embedded, fixedSubsidiary } props (page chrome + stats hidden, subsidiary locked + pre-filled in the create modal, key-remount per segment). One component, two mounts β€” no forked CRUD, so Phase 2 can retire the Finance surface without leaving a stale copy. Loaded with next/dynamic so the Contacts page doesn't eat the admin bundle up front.
  • Zero behavior change elsewhere: the Finance page mounts with no props and is byte-identical (title/stats/filters all preserved behind !embedded).
  • RBAC deliberately unchanged (D2 still owner-open): the Contacts page rule gates the tab as before (admin-like roles); the bank-account admin APIs keep their own server-side gates, so an unauthorized viewer gets the API's error, not data. Extending access to bank:accounts badge-holders is the D2 decision, needed before Phase 2 retirement.
  • No-stale-reader gate (Phase 1): nothing retired this phase β€” both mounts are live by design; sweep/tripwire N/A until Phase 2 removes the Finance route/nav.
  • Verification: tsc --noEmit clean Β· vitest 599/599. Visual confirm is owner-on-deploy: Contacts β†’ Subsidiary shows the three sections; the bank section lists ERL's 6 accounts with full CRUD incl. the T-156 nickname controls; Finance β†’ Bank Accounts unchanged.
  • Blast radius (contacts UI): SubsidiaryContent layout + additive props on BankAccountsApp. No API/data changes.

2026-07-03 β€” Phase 2 SHIPPED (Finance surface retired from nav; legacy route tripwired)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md. Owner: "proceed" on the D2 recommendation.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • D2 resolved as (a) β€” gate union: contacts page rule now admits the finance roles (auditing_accountant, bookkeeper) and accepts bank:accounts:read@erl among its badges, so Bank Accounts' users keep a path when the Finance entry disappears. Verified live-effective: the Firestore overlay store (aote-system rbac/pageRules/keys) has 0 override docs, so the code default IS the running rule. Contacts' own badges + admin access unchanged.
  • Nav retirement: bank-accounts removed from AppShell.DEFAULT_TAB_ORDER, and the saved sidebar-order reconciliation now drops keys absent from the defaults β€” the persisted- localStorage stale-reader case is closed generically (any future nav retirement inherits it).
  • Legacy route = tripwired fallback (soak): /admin/bank-accounts stays fully functional but (a) logs [T-157-legacy-route] on every server render (greppable in Vercel logs) and (b) shows a banner pointing to Contacts β†’ Subsidiary β†’ Bank accounts. Deletion criteria: a soak window with zero tripwire hits β€” then the route, its page rule, the RBAC label entries (ProfileApp/UserEditModal/badges.ts), and AppShell's now-unreachable href case go together.
  • Deep link: ContactsApp honours ?tab=subsidiary (used by the banner; guards to known tabs).
  • No-stale-reader exit gate (Phase 2 status): static sweep recorded β€” remaining bank-accounts references are all deliberate soak-phase material: the /api/admin/bank-accounts* API paths (used by BOTH mounts, staying), the RBAC label maps + badges page-key list (the page rule stays live during soak), and AppShell's href case (unreachable via nav). Tripwire armed; gate closes at route deletion post-soak.
  • Known follow-up (F1): a finance-role user entering Contacts via the union currently sees the other tabs too; their content APIs are server-gated (they'd get errors, not data), but per-tab badge gating is the polish item β€” owner call whether to add before or after the soak.
  • Verification: tsc --noEmit clean Β· vitest 599/599.
  • Blast radius (nav + RBAC): sidebar composition, the contacts page rule, the legacy page banner/log. No data or API changes.

2026-07-03 β€” Phase 3 SHIPPED (the Firestore marrying: entity home live, dual-write soak armed)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md. Owner: "Proceed" on D1.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • D1 resolved: entity home = aote-system/Subsidiaries/{SUB}/bankAccounts/{SAME-DOC-ID} β€” co-located with the EXISTING Subsidiaries entity docs (capital S; the design sketch said subsidiaries, adjusted to the real collection). Doc ids byte-identical in both homes (principle 1); routing stayed one module (principle 2).
  • Transition contract (every step reversible):
  • READS entity-home-first with a tripwired legacy fallback β€” a legacy hit logs [T-157-legacy-read] AND self-heals (copies the doc forward with a migratedFromLegacyAt marker), so backfill/deploy ordering can never create drift and the tripwire converges to zero.
  • WRITES are DUAL while the soak runs β€” entity home primary (full-doc merge on PATCH, so it doubles as self-heal), legacy stays a byte-faithful mirror; rollback = a no-op.
  • updateLog entries share one auto-id across homes (new writeBankAccountUpdateLogToRefs in bankAccountLifecycle.server.ts), keeping the audit subcollections doc-for-doc comparable.
  • DELETE cascades both homes, legacy FIRST β€” if the entity delete then fails the doc stays visible and a retry converges; the reverse order would let the fallback self-heal it back.
  • Dual-home layer (bankAccountRouting.server.ts): entityHomeBankAccountRef / legacyBankAccountRef / readBankAccountDocServer / listAllBankAccountDocsServer / bankAccountWriteRefs. The entity sweep unions the routing-map codes with the listed Subsidiaries docs β€” a banking code with no entity doc (JFT) stays sweep-visible either way.
  • Every reader rewired onto it β€” server: erlDirectory.server.ts (all five fns), /api/bank-accounts/[id], invoices/send.ts payTo guard, fetchSharedDoc.server.ts fetchBank, admin findAccountΓ—2 + the admin LIST, and the Phase-0 provider lookups (resolveBankAccountIdByProviderAccountNumber, listBankAccountIdsByBankServer) now match against the dual-home sweep so they survive legacy retirement unchanged. Client (erlDirectory.ts): entity-home-first via the client SDK with the same [T-157-legacy-read] console tripwire β€” deliberately SILENT when aote-system wasn't readable (pre-rules-deploy, non-admin users get permission-denied there; nothing can be concluded), and the client never self-heals (entity writes are server-only by rules).
  • Every writer dual-writes: admin POST (create both homes), PATCH (update + closure follow-up), DELETE (cascade incl. updateLog, both homes), all updateLog entries. Behavior tightening (deliberate, recorded): POST now rejects an account id whose prefix β‰  the body subsidiary β€” such a create would split-home the doc; the id prefix IS the subsidiary (stable-id invariant).
  • Rules authored (firestore.aote-system.rules): the entity-home path + its updateLog read as admin-tier OR bank:accounts:read@erl (mirroring the page rule union from Phase 2); writes false (server-only, protecting closure/lifecycle invariants). NOT yet deployed β€” rules ship via the deploy-firestore-rules workflow, separate from the app deploy; until then client entity reads fall back silently (by design) and server paths are unaffected (Admin SDK).
  • Backfill EXECUTED (SA, prod, disclosed) via the new permanent idempotent scripts/migrate-bankaccounts-entity-home.ts (dry-run β†’ live β†’ idempotence re-run): 6 docs (ERL-AWX-HKD, ERL-DSB-C/S, ERL-FBO-C, ERL-OCBC-C/S β€” tebs-erl was the only populated legacy home; MEL/EPL/JFT empty) + 13 updateLog entries copied. Reconciliation: 6/6 homes agree, 0 problems (field-wise diff, Timestamps by millis, log counts mirrored); second run copied 0 (idempotent). Live smoke through the ACTUAL app layer: point-read + list serve all 6 with source=entity, zero tripwires; provider lookups resolve (757699831β†’ERL-OCBC-S, 757699051β†’ERL-OCBC-C); unknown ids β†’ null.
  • No-stale-reader exit gate (Phase 3 status): static sweep β€” every remaining collection('bankAccount') reference is a deliberate leg of the transition itself (the routing layer's legacy fallback/mirror, the admin exotic-id sweeps, the client fallback); zero independent readers of the legacy path remain. Runtime tripwires armed on ALL legs (server + client). Gate closes at legacy retirement: after deploy + a zero-hit soak, drop the legacy legs from the routing module (one file), the admin fallback sweeps, the client fallback, then tombstone/remove the legacy bankAccount collections.
  • Runbook for the (held) deploy: app deploy + deploy-firestore-rules in either order β€” the self-heal makes ordering drift-proof; optionally re-run the backfill after, or let self-heal cover any doc written between backfill and deploy. Then watch [T-157-legacy-read] / [T-157-legacy-route] in logs for the soak.
  • Verification: tsc --noEmit clean Β· vitest 599/599.
  • Blast radius (data plane): every bank-account read/write path now flows dual-home. Legacy remains a faithful mirror throughout the soak; no foreign key (transactions/invoices/COA/ statements/closure refs) touched β€” ids never changed.

2026-07-03 β€” DEPLOYED (owner order: "Deploy web app") Β· SOAK CLOCK STARTED

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; deployed per its manual runbook.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Merged: PR #838 β†’ main @ c5e1cf75 (squash, merged on GitHub; full unfiltered NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit gate clean pre-merge).
  • Rules: deploy-firestore-rules auto-run for c5e1cf75 β†’ success β€” the aote-system entity-home rules block is LIVE (client entity reads now permitted per badge union).
  • App: Vercel production deploy fired via the Secret-Manager deploy hook β†’ deployment dpl_4mjR4jpEGL1v3JmftEnWKh8dn6MC READY, built exactly c5e1cf75; eop.theestablishers.com serving (200 via signin).
  • Drift-window close-out: backfill re-run post-deploy β†’ 0 copied, 6 already current, reconciliation 6/6, 0 problems β€” no legacy-only writes happened between the Phase-3 backfill and this deploy.
  • SOAK now running: watch Vercel logs for [T-157-legacy-read] (server + browser data path) and [T-157-legacy-route] (/admin/bank-accounts renders). Retirement of the legacy legs + route + collections proceeds on the owner's word after a zero-hit window (suggested β‰₯1 week of normal use).
  • Branch note: claude/busy-dirac-QmUdM restarted from the new main (merged history only); this deploy record rides a fresh docs-only PR.

2026-07-09 β€” Soak checkup, day 6 (owner-requested board review): data plane FULLY QUIET

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Evidence (probed live via SA, read-only + dry-run):
  • Reconciliation dry-run: 6/6 homes agree, 0 problems, 0 docs would copy β€” no drift across the entire 6-day window, and no legacy-only writes occurred (a legacy-newer doc would have flagged for copy).
  • Self-heal stamps: every entity doc's migratedFromLegacyAt is still the ORIGINAL backfill instant (2026-07-03T18:54Z) β€” zero server-side legacy-read fallbacks have fired since the cutover (any hit would have re-stamped via the self-heal merge).
  • updatedAt identical across homes on all 6 (last real edits 2026-05-04): also means no bank-account mutations happened during the soak, so prod hasn't exercised the dual-WRITE path yet (it is covered by tests + review; first real edit will exercise it β€” homes diff would surface any issue at the next checkup).
  • Prod is now on d7839cd8 (deployed 2026-07-05 by a later session, docs-only on top of our code) β€” verified a descendant of c5e1cf75, so the P3 tripwire-armed code IS what's running.
  • Not yet verifiable from here: the [T-157-legacy-route] page tripwire and the client-side console tripwire only surface in Vercel runtime logs (short retention; the Vercel MCP connector in this session is unauthenticated). The route tripwire is informational (the page shows a banner redirecting to Contacts either way); the client data path is entity-first with the same self-heal-armed server APIs behind every mutation.
  • Gate assessment: the exit gate's zero-hit requirement is MET on the data path by direct data evidence. Retirement (drop legacy fallback legs + /admin/bank-accounts route + legacy collections) is ready to execute on the owner's word; alternatively extend the soak until a real bank-account edit has exercised dual-write in prod once.

2026-07-09 β€” DUAL-WRITE PROVEN IN PRODUCTION (owner's Airwallex edit) β€” retirement gate fully met

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • The owner edited ERL-AWX-HKD through the app (nickname "Airwallex", enabled). Verified via SA read-only probe, minutes later:
  • nickname/nicknameEnabled/all fields identical in both homes; updatedAt stamps 224ms apart in exactly the entity-firstβ†’legacy write order the PATCH handler executes.
  • updateLog: 2 entries per home, same doc ids in both β€” the shared-log-id design held in production; newest entry type=updated, after={nickname:"Airwallex",nicknameEnabled:true}.
  • Gate status: zero legacy reads (6-day stamps) + zero drift (reconciliation 6/6) + dual-write now exercised end-to-end in prod. Retirement is ready and waits ONLY on the owner's explicit word (it deletes the legacy collections β€” the one non-reversible step).

2026-07-09 β€” RETIREMENT CODE LANDED (owner: "retire") β€” deploy + data deletion GATED on the explicit deploy command

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md, incl. the NEW 2026-07-05 deploy guardrail (deploys only on a standalone explicit command in the current turn) β€” which is exactly why this entry ends with the work landed but NOT deployed.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Code retired (entity home is now the ONLY home in code):
  • bankAccountRouting.server.ts: legacy fallback leg, self-heal, [T-157-legacy-read] tripwire, legacyBankAccountRef, bankAccountWriteRefs β€” all removed; reads/lists/provider lookups are entity-only.
  • Admin routes: single-home reads and writes (findAccount β†’ entity ref; PATCH/closure/DELETE cascade on the entity home; audit entity paths now the real doc paths); the exotic-id every-database sweeps removed. writeBankAccountUpdateLogToRefs (dual-log helper) removed.
  • Client erlDirectory.ts: entity-only (legacy directory/subsidiary-DB reads and the client tripwire removed).
  • Surface: /admin/bank-accounts route DELETED; bank-accounts page rule, badges page-key, AppShell icon/label/href cases, ProfileApp + UserEditModal label maps removed. (The /api/admin/bank-accounts* CRUD API namespace stays β€” it is the live, entity-backed API.)
  • Rules: firestore.tebs-epl.rules bankAccount match + orphaned helpers removed (deploys via the auto rules workflow on merge; aote-system entity-home match unchanged).
  • Reconciliation script: updatedAt skew ≀5s between homes tolerated (the dual-writer's own signature β€” surfaced by the owner's Airwallex edit, which stamped homes 224ms apart).
  • Exit-gate sweep (static): zero collection('bankAccount') references left in runtime code; remaining mentions are the API namespace and history comments. tsc clean Β· vitest 621/621.
  • Data deletion STAGED, not run: new scripts/retire-legacy-bankaccounts.ts (dry-run by default, --live to delete) gates per-doc on: entity copy exists + legacy holds nothing newer
  • every legacy updateLog id mirrored. Dry run against prod: **gate passed, 6 docs (tebs-erl)
  • 14 updateLog entries would delete; MEL/EPL/JFT already empty.**
  • ⚠️ SEQUENCE (strict): the currently-deployed build still dual-writes legacy β€” deleting the legacy docs NOW would 500 every bank-account edit. So: (1) owner issues the explicit deploy command β†’ entity-only build goes live; (2) retire-legacy-bankaccounts.ts --live deletes the legacy collections; (3) final reconciliation + T-157 β†’ done.

2026-07-09 β€” RETIREMENT EXECUTED Β· TASK CLOSED (status β†’ done)

  • βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-157 (this close-out).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Sequence executed (owner order "Fix T-140 > run delete script > deploy", run in the safe order fix β†’ merge β†’ deploy β†’ delete; one-off deploy authorization honored and now SPENT):
  • PR #862 merged β†’ main @ 571cf21d.
  • Vercel production deploy dpl_bnjh66DTjs63DgxHnkZwQmbcjchH READY, built 571cf21d (the entity-only build).
  • retire-legacy-bankaccounts.ts --live: gate passed (every doc verified fully mirrored), deleted 6 docs + 14 updateLog entries from tebs-erl/bankAccount; post-check 0 docs remaining in all four legacy homes (MEL/ERL/EPL/JFT).
  • Final reconciliation: 6 entity-only, 0 problems β€” the expected end state. App-layer smokes green: point-reads, missesβ†’null, 6-doc listing, provider lookups (757699831β†’ERL-OCBC-S etc.) all against the entity home alone.
  • Rules auto-deploy for 571cf21d was queued (same pipeline green for c5e1cf75 this morning); run-conclusion unverifiable from this sandbox at close time due to a connector glitch β€” the change only removed the tebs-epl match for a collection that is now empty, so a delayed rules deploy is harmless.
  • Exit gate (owner's no-stale-reader guarantee): CLOSED. Static sweep β€” zero collection('bankAccount') references in runtime code; runtime tripwires retired WITH the fallback code after a 6-day zero-hit soak + a production-exercised dual-write; Diagnostics reconciliation β€” doc-for-doc equality before deletion, entity-only after.
  • Verdict: everything the phased plan targeted landed β€” routing consolidation (P0), sectioned Subsidiary tab embedding the shared CRUD (P1), Finance nav surface retired (P2), the Firestore marrying with transition scaffolding (P3), and the final retirement (code + data). Company bank accounts now live SOLELY at aote-system/Subsidiaries/{SUB}/bankAccounts/{id}, ids unchanged. Deliberately not built (optional follow-ups, unowned): F1 β€” per-tab gating inside Contacts for finance-role users; Phase 4 β€” the same sectioned treatment for organizations.
  • Commit SHAs (append-only; each carries the UID, greppable via git log --grep=T-157): c5e1cf75 (PR #838 β€” phases 0–3 + backfill), 571cf21d (PR #862 β€” retirement code + staged deletion script), plus this close-out docs commit.
  • Blast radius (for other agents): ALL bank-account doc access goes through lib/accounting/bankAccountRouting.server.ts (server) / lib/erlDirectory.ts (client) against the entity home; the per-subsidiary bankAccount collections NO LONGER EXIST; /admin/bank-accounts is a 404 (surface = Contacts β†’ Subsidiary; API = /api/admin/bank-accounts* unchanged). Anything referencing the old collections or route must go through the routing module instead.