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¶
bankAccountIdis immutable, forever. Every phase preserves the exact doc ID as the join key. Re-homing moves documents, never identities.- Route through one module. All prefixβDB/collection resolution goes through
a single
bankAccountRoutingmodule before any doc moves β flipping the home later = one file. - 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.
- Each phase independently shippable + reversible. No phase depends on a later one; every phase has a verification gate (Diagnostics reconciles data before/after).
- One section model, two schemas honored. Company accounts keep
BankAccountRecord(status/lifecycle/closure β load-bearing); individuals keepPayeeBankAccountEntry[]. 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:
- 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).
- 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. - 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)¶
- Stable-ID foreign keys in 5+ stores (transactions/invoices/COA/statements/ closure refs) β never rekey; Phase 3 keeps IDs byte-identical.
- PrefixβDB routing Γ10 β solved by Phase 0 before anything moves.
- Closure lifecycle β ERL ledger coupling β regression-test closure preview + settlement on every phase that touches storage or APIs.
- Company vs individual schema mismatch β mapped at the section-UI layer, not merged in storage (principle 5).
- Rules asymmetry β Phase 3(a) authors explicit rules in the new home; interim phases rely on Admin-SDK server APIs exactly as today.
- RBAC gate divergence β Phase 1 defines the union gate deliberately (decision for Infra + owner below).
- Invoice issuance guard + XLSX synchronous resolution β dual-read shim keeps
/api/bank-accounts/[id]+ the send.ts inline lookup resolving throughout. - Nav/localStorage tab-order β Phase 2 reconciliation logic already exists in
AppShell (
sidebar-tab-ordermerge); reuse it.
Decisions needed before build (Infra proposes, owner ratifies)¶
- D1. Final doc home:
aote-systemsubsidiaries 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
contactswith the bank badge, or per-section gating inside the page? - D3.
/api/accounting/bank-accounts'srequireAuth({subsidiaries:['epl']})oddity β correct toerl(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
mainby 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(resolveBankAccountIdByProviderAccountNumberwith 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(inlineBANK_DBS),lib/share/fetchSharedDoc.server.ts(map +fetchSubsidiary/fetchBanklookups), the three admin routes (admin/bank-accounts/index.ts,[id].ts,closure-preview.tsβ map + localextractSubsidiarynow delegate, defaults'ERL'/''preserved),pages/api/accounting/bank-accounts.ts(2Γsplit('-')[0]),lib/accounting/csvParser.ts.pages/api/subsidiaries/[id].tskeeps 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
JFTnow include it β JFT-* account ids resolve, and the admin list enumerates thejefferochanDB (per-db try/catch tolerates absence). Recorded here so nobody reads the widened map as an accident. - Provider maps β registry: OCBC sync's
getOCBCBankAccountIdis now registry-first (providerAccountNumberon thebankAccount/{id}doc) with the legacy literals as a zero-behavior-change fallback; the OCBC delete-transactions route derives its id list vialistBankAccountIdsByBankServer('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_ABBREVIATIONSinbankAccountUtils(sync client-side query lists β converting to async registry reads changes call shapes; a later-phase item), dev scripts' literals, and the cosmeticsplit('-')short-code display inTransactionDetailsDrawer(not routing). - Verification:
tsc --noEmitclean Β· vitest 599/599 (incl. the new routing tests) Β· repo-wide grep: zero remaining inlinetebs-mel/tebs-erl/tebs-eplmap 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:
SubsidiaryContentis now a sectioned entity profile β Company identity (names/address/contact) Β· Legal & filings (BR no., IRD employer file) Β· Bank accounts. The bank section mounts the SAMEBankAccountsAppused 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 withnext/dynamicso 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:accountsbadge-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 --noEmitclean Β· 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:
contactspage rule now admits the finance roles (auditing_accountant,bookkeeper) and acceptsbank:accounts:read@erlamong 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-accountsremoved fromAppShell.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-accountsstays 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-accountsreferences 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 --noEmitclean Β· 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 EXISTINGSubsidiariesentity docs (capital S; the design sketch saidsubsidiaries, 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 amigratedFromLegacyAtmarker), 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
writeBankAccountUpdateLogToRefsinbankAccountLifecycle.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 listedSubsidiariesdocs β 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.tspayTo guard,fetchSharedDoc.server.tsfetchBank, adminfindAccountΓ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 + itsupdateLogread as admin-tier ORbank:accounts:read@erl(mirroring the page rule union from Phase 2); writesfalse(server-only, protecting closure/lifecycle invariants). NOT yet deployed β rules ship via thedeploy-firestore-rulesworkflow, 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 withsource=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 legacybankAccountcollections. - Runbook for the (held) deploy: app deploy +
deploy-firestore-rulesin 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 --noEmitclean Β· 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 unfilteredNODE_OPTIONS=--max-old-space-size=8192 tsc --noEmitgate clean pre-merge). - Rules:
deploy-firestore-rulesauto-run forc5e1cf75β 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_4mjR4jpEGL1v3JmftEnWKh8dn6MCREADY, built exactlyc5e1cf75;eop.theestablishers.comserving (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-accountsrenders). 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-QmUdMrestarted from the newmain(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
migratedFromLegacyAtis 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). updatedAtidentical 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 ofc5e1cf75, 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-accountsroute + 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;updatedAtstamps 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 entrytype=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-accountsroute DELETED;bank-accountspage 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.rulesbankAccount match + orphaned helpers removed (deploys via the auto rules workflow on merge; aote-system entity-home match unchanged). - Reconciliation script:
updatedAtskew β€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,--liveto 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 --livedeletes 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_bnjh66DTjs63DgxHnkZwQmbcjchHREADY, built571cf21d(the entity-only build). retire-legacy-bankaccounts.ts --live: gate passed (every doc verified fully mirrored), deleted 6 docs + 14 updateLog entries fromtebs-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
571cf21dwas queued (same pipeline green forc5e1cf75this morning); run-conclusion unverifiable from this sandbox at close time due to a connector glitch β the change only removed thetebs-eplmatch 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-subsidiarybankAccountcollections NO LONGER EXIST;/admin/bank-accountsis 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.