Skip to content

Anchor coaching Sessions to the Individual stableId (replace name/abbr matching)

Owner idea (2026-06-16)

Now that student info is merged into unified Individual profiles, stop linking coaching sessions by student abbreviation / name-match and instead point each session at the Individual by stable id.

Current state (mapped)

  • tebs-mel/Sessions is ALREADY a single ROOT collection (not grouped under students) β€” so that half of the idea already holds. βœ…
  • Sessions link to students by a NAME/STRING match: Sessions.sessionName == Students.account (the frozen account field), seen in lib/sessionStats.ts:20 (account = sessionDoc.sessionName) + SessionsTab/useBillingInfo. Some cache-invalidation paths also carry studentAbbr (the Students doc-id), e.g. notifyCoachingChange / useCoachingSync.
  • The Students doc carries the Individual link already? NO β€” the Individual carries the student's frozen abbr as an ALIAS + (from P3) the student's personal info; the Session does not yet carry an individualId.

Assessment (my take β€” it's a GOOD direction, but a real project)

  • PRO: a stable individualId anchor is far more robust than matching on a free-text name (account/sessionName), which is fragile to renames/typos and is exactly the kind of name-keying T-048 set out to fix (R4).
  • COST / RISK (why it's its own task, not a quick change):
  • Calendar sync is the session creator and matches Google Calendar events to students BY NAME (account == sessionName). Re-anchoring means the sync must resolve individualId at creation time (name -> student abbr -> individualId via the abbreviations lookup) and stamp it on the session.
  • Backfill every existing Session with its individualId (resolve via account/abbr -> abbreviations lookup -> individualId), dual-key during cutover (keep sessionName/account working until all readers move).
  • All session readers (sessionStats, useBillingInfo, SessionsTab, invoice generation) move to individualId.
  • Sessions DRIVE coaching invoices (financial) β€” migrate dry-run-first, detect-don't-correct, keep the frozen account match as a fallback until proven.

Recommendation

Do it as a deliberate, dry-run-first migration AFTER T-048 has settled, mirroring the T-048 playbook (snapshot -> propose-link artifact -> additive stamp -> dual-read -> flip -> retire name-match). Not urgent; the current name-match works. Logged so it isn't lost.

Log

  • 2026-06-16 created from owner idea during T-048 archival. Confirmed Sessions are already a root collection; the change is the ANCHOR (name/abbr -> stable individualId) + the Calendar-sync resolution. See T-048.

2026-07-15 β€” picked up (owner routing via EOP Local Assistance); investigation + plan

βœ… Attestation

Read AGENTS.md; checked the board by scope β€” T-052 is the coaching-side catch-up to the T-048/T-081 contacts unification (the payee/rep/director side already gained individualId; the tebs-mel Students/Sessions side is the piece that was held out). Closest neighbours are T-079/T-146 (nested Sessions β€” I wrote the latter) β€” neither anchors Sessions to individualId, so not a duplicate. Flipped todo β†’ doing. Source: Coaching (Diagnostic) Β· https://claude.ai/code/session_01WfT2gXvbegVjgztHbHuGej

Owner intent (2026-07-15, verbatim)

"this part was held out because we need the individuals Firestore doc UID instead of just their student abbrev because we're re-writing for those."

Confirms the deliverable: coaching records must carry the Individual's Firestore doc UID (individualId) as the stable anchor, replacing the free-text name/abbr match.

The contacts-unification design R1 (SETTLED β€” docs/contacts-unification-design.md:45-50, case D line 161) is explicit: "Operational docs KEEP their original abbreviation as their FROZEN doc-id forever (payees/{abbr}, Students/{abbr}) … gain an individualId link. The Individual's abbreviation is display/identity, never a doc key." Students/Geet is the doc's own worked example of "stays untouched." So T-052 = additive link: stamp individualId, retire the name-match, keep the frozen Students/{abbr} doc-ids + nested paths β€” no doc moves, no re-key. (A full doc-id re-key was considered and explicitly declined as an R1 override; it would carry the I-029-class financial-doc-move risk + break the Apps Script resolveAbbrByAccount_, lib/paths.ts, and the coaching page's abbr entry β€” a separate task if ever wanted.)

Live-state investigation (read-only, tebs-mel + aote-system)

  • 11/11 Students resolve to a unique Individual UID via the production resolver (resolveIndividualIdByAbbreviation β†’ canonicalAbbreviation over abbreviation + basic.aliases[]). 9 resolve on the abbreviation; the 2 abbr-mismatch cases (the exact fragility this task kills) resolve via alias β€” Geetβ†’E6g30MzNOkhzlCJwKvoe (Jake Ngai / JN director) and KTβ†’hs1vn2nLrvS4v1KGQ0ks (KIRI). Verified map:
abbr account individualId
Asa Asaly Yuen ytWqr7tQsRztEPJ78gcI
Ceh Cehryl 5wxKo7qWZx4QlRsd8mIv
ET Edward Tse bvMOXaGU9wbDmKwmnGcE
Geet Jake Ngai E6g30MzNOkhzlCJwKvoe
KT Kiri T hs1vn2nLrvS4v1KGQ0ks
ML Matthew Lee KS5hkMzV08D0blI0zyT9
MT Moon Tang VlzZLqoujs7lrLHCdUEI
Nan Nancy Kwai tUaC8XltDnS3qISUIwoS
OJ Ojay Reambillo vLvelXGblgMkiPvrD4rA
RYu Rachel Yu 9gV6VZvIw4NLGhEKAquc
TC Lok Chow jiTSl2nCWCyutb3ZQni7
  • Nothing carries individualId yet β€” not the 11 Student docs, not the 113 sessions (repo-wide grep + live probe). Clean additive field.
  • 113 sessions, all nested (Students/{abbr}/Sessions, 0 top-level, 0 phantom parents); every session's parent abbr resolves (0 unresolved). 15 carry the invoice map (13 Nan + 2 MT β€” the I-029 cohort, all present post-restore).
  • I-029 lesson locked in: the T-146 re-home dropped 5 Nan invoice maps (fork agent restored them; full 113-sweep clean; PITR now 7-day). Scope A moves no docs, so no I-029-class risk; still, a pre-write PITR timestamp is taken and every write is per-doc verified.

Code-site map (full-repo exploration)

  • Resolver: lib/individuals/abbreviations.server.ts:43 (server-only, Admin SDK, scans aote-system/individuals).
  • Dual-read seam: lib/coaching/sessionsSource.ts:64 getStudentSessionDocs(abbr, account) β€” the single client seam; still name-matches the legacy top-level fallback (sessionName==account, line 73).
  • The creator: Apps Script apps-script/SessionSync.js handleCalendarItem_ (124) β†’ resolveAbbrByAccount_ (apps-script/Utils.js:64) β†’ writes Students/{abbr}/Sessions/{id} (243); no individualId.
  • Name/account/abbr readers: sessionsSource, sessionStats.ts:20/48, balance.ts:58, useBillingInfo, SessionsTab, CoachingSessionsApp, billing/compute.ts, payment-receipt client:271-309, useCoachingLiveSync.ts:78. Server readers already key by sessionId/collectionGroup (tolerate the layout).
  • Web writers: RateModal (rateCharged), SessionDetail (sessionVoucher), invoice writer client.tsx:681, SessionsTab summary.

Anchor on the Student doc as source of truth: sessions are nested under Students/{abbr} and the resolver is server-only, so stamp individualId on the 11 Students first; the session backfill and the Apps Script sync then derive it locally in tebs-mel (no cross-DB hot path). Also denormalize individualId onto each session so collectionGroup('Sessions').where('individualId','==',X) works and server reads need no parent lookup. - Phase 0 β€” investigation + verified propose-link artifact. Read-only. βœ… (this entry) - Phase 1 β€” additive stamp (owner-gated write). Dry-run-first script (backup + PITR timestamp) stamps individualId on 11 Students + 113 sessions from the verified map; never overwrites a populated field; per-doc verify after. No doc moves. Reversible. - Phase 2 β€” sync at creation (owner clasp deploy). Apps Script handleCalendarItem_ reads Students/{abbr}.individualId and stamps it on new sessions. Owner chose to include this in-task. - Phase 3 β€” dual-read β†’ flip. getStudentSessionDocs + the name-match readers gain an individualId arm; once every reader carries it, retire the sessionName==account fallback. detect-don't-correct; name-match stays a dormant fallback until proven.

2026-07-15 β€” Phase 1 APPLIED + verified (additive stamp, live tebs-mel)

  • scripts/backfill-session-individual-id.ts --apply β€” stamped individualId on 11/11 Students + 113/113 Sessions (additive set(...,{merge:true}); backup-first; PITR recovery point 2026-07-09T09:56:57Z; per-doc read-back verify; I-029 invoice-map guard passed). Owner said "continue" on the clean dry-run.
  • Verified (independent plain read): 113/113 sessions carry individualId, 0 missing; grouped counts match the roster exactly (KT 50, Nan 22, TC 12, MT 11, ML 7, OJ 4, RYu 3, Asa/Ceh/ET/Geet 1); all 15 invoice-map sessions intact; 11/11 Students stamped. No doc moved, no other field touched β†’ R1-preserved.
  • Index added (needs deploy): firestore.indexes.json β€” a Sessions.individualId fieldOverride with a COLLECTION_GROUP scope so collectionGroup('Sessions').where('individualId','==',X) resolves (it currently FAILED_PRECONDITIONs without it). Rules auto-deploy on merge, but indexes are a manual deploy (firebase deploy --only firestore:indexes) β€” owner step, mirrors the T-079 invoice-number index.
  • Backup: scripts/migration-backups/t052-individualid-2026-07-09T09-56-57-650Z.json (local/gitignored). Rollback = delete the field or PITR to the recovery point.
  • Next: Phase 3 (dual-read: getStudentSessionDocs + readers prefer individualId, keep name-match fallback) and Phase 2 (Apps Script handleCalendarItem_ stamps individualId from Students/{abbr}.individualId at creation β€” owner clasp deploy).

2026-07-15 β€” index CORRECTION: created via Admin API (not the manual deploy)

  • The owner's firebase deploy --only firestore:indexes ran from a checkout behind main, so it deployed the old firestore.indexes.json (no individualId override) β€” the index never got created. Instead I created it directly from the cloud env via the Firestore Admin API (FirestoreAdminClient.updateField, adding the Sessions.individualId single-field override with COLLECTION/COLLECTION_GROUP scopes). Built in ~150s; verified live β€” collectionGroup('Sessions').where('individualId','==',X) returns Nanβ†’22, KTβ†’50. So the earlier "needs manual deploy" note is superseded: index is live, no owner action needed. (The tracked override on main still matches, so future deploys are consistent.)

2026-07-15 β€” Phase 3 SHIPPED (web dual-read seam) + a sync bug discovered

  • lib/coaching/sessionsSource.ts getStudentSessionDocs now reads the canonical nested sessions and, for the top-level fallback, unions two keys: the T-052 individualId anchor (read off the nested docs) and the legacy sessionName==account match β€” merged by id, nested wins. Surfaces individualId on CoachingSessionDoc. Behavior-preserving (top-level empty β†’ identical result set); verified live via SA mimic (Nan 22/22, KT 50/50, Geet 1/1). tsc --noEmit clean. No caller signature changes.
  • Name-match NOT retired yet (deliberate): the calendar sync still writes a top-level Sessions/{id} when it can't resolve a student to an abbr; those have no individualId, so the sessionName match remains their only link. Retirement waits on Phase 2 making the sync reliably attribute + anchor sessions.
  • ⚠️ Discovery (blocks a clean Phase 2): apps-script/Firestore.js writeFirestoreDoc issues a Firestore REST PATCH with no updateMask, which REPLACES the whole document (proven on a throwaway doc β€” a sibling invoice map was dropped). So the calendar sync wipes a session's invoice map (and would wipe individualId) every time it re-writes an existing session β€” a latent I-029-class invoice-loss risk that survives only because invoice-bearing (past) sessions apparently aren't re-processed by the current run window. Phase 2 must therefore switch the session upsert to a merge write (updateMask = the written field paths) so re-syncs preserve invoice/individualId/createdAt β€” then stamp individualId from Students/{abbr}.individualId. Both are deployed-Apps-Script changes β†’ owner clasp deploy. Flagged to owner.

2026-07-15 β€” Phase 2 IMPLEMENTED (Apps Script; awaits owner clasp deploy)

  • apps-script/Firestore.js β€” new mergeFirestoreDoc(path, data): PATCH with an updateMask of exactly the written field paths, so it sets those and leaves every other server field untouched (verified via the exact POST + X-HTTP-Method-Override:PATCH mechanism: masked write preserves a sibling invoice map; unmasked replaces it). writeFirestoreDoc kept (+ a ⚠ doc-comment) for the history/meta writes that intend a full body.
  • apps-script/SessionSync.js: both session-doc writes now use mergeFirestoreDoc β€” the main upsert (was line 245) and the cancelled-branch sessionType:'Cancelled' write (was 161). The two non-session writes (appointmentHistory sub-doc @403, coaching/meta @474) stay writeFirestoreDoc (fresh doc / sync-owned signal). This fixes the latent invoice-wipe regardless of T-052.
  • individualId stamp at creation: handleCalendarItem_ resolves resolveIndividualIdByAbbr_(abbr) (new in apps-script/Utils.js β€” reads Students/{abbr}.individualId, per-run cached) and stamps data.individualId when resolved. Unattributed top-level sessions (no abbr) get none β†’ web-app name-match still covers them.
  • All three files pass node --check; additions are ES5 (backticks only in JSDoc). Not deployed β€” Apps Script ships via clasp push (owner's Google login). Handoff test: clasp push from apps-script/, run auditAllEvents, confirm (a) a re-synced session keeps its invoice map, (b) new sessions carry individualId.
  • Deferred to after this deploy proves out: retire the sessionName==account read fallback in getStudentSessionDocs (Phase 3), since the sync will then reliably anchor sessions.

2026-07-15 β€” βœ… DONE β€” Phase 2 DEPLOYED + verified live (closing verdict)

  • βœ… Attestation (Coaching (Diagnostic)): read AGENTS.md; closing per the owner policy that done = merged to main (deploy not a prerequisite) β€” here Phases 1–3 are merged AND the Apps Script (Phase 2) is deployed and production-verified. Source: Coaching (Diagnostic) Β· https://claude.ai/code/session_01WfT2gXvbegVjgztHbHuGej
  • Deploy: owner ran clasp push (8 files pushed) + auditAllEvents (2026-07-15 11:00 HKT). I verified the deployed HEAD byte-matches the repo and carries every Phase-2 marker (mergeFirestoreDoc, both session writes merged, individualId stamp, resolveIndividualIdByAbbr_). (I could NOT deploy via the env-var SA: the Apps Script API updateContent/scripts.run need the per-identity "Apps Script API" user toggle, which a service account can't hold β€” SA is read-only on Apps Script. Deploy is the owner's clasp push.)
  • Live proof of the merge-write fix: the auditAllEvents run re-wrote 7 sessions (updatedAtβ†’today); 4 of them carry invoice maps and every invoice survived the re-write. Under the old full-replace writeFirestoreDoc those 4 invoices would have been wiped. Post-run: 15/15 invoice maps intact (0 lost), individualId 113/113, 0 top-level, no dupes.
  • Outcome vs. plan (all delivered): (1) individualId stamped on 11 Students + 113 Sessions [live]; (2) Sessions.individualId collection-group index [live β€” created via the Firestore Admin API after the owner's stale-checkout deploy no-op; query returns Nanβ†’22/KTβ†’50]; (3) web read seam anchors on individualId [merged f44da72, ships next Vercel deploy]; (4) calendar sync merge-writes + stamps individualId at creation [deployed + verified]. R1 honored throughout β€” no doc moved, no doc-id re-keyed.
  • Name-match NOT retired β€” by design, permanent: the sync still writes a top-level Sessions/{id} (no abbr, no individualId) when it can't resolve a student, so the sessionName==account arm stays as the edge-case link for unattributed sessions. individualId is the primary anchor for all attributed sessions; name-match is demoted to a fallback, not removed. (A future task could make the sync always attribute, then drop it.)
  • Clock-skew caveat: this cloud sandbox's clock ran ~6 days behind (stamped 2026-07-09 while real time was 2026-07-15, confirmed by the Firestore server updatedAt). The two …-07-09T09:56:57… literals above (PITR recovery point + backup filename) are the real bytes the backfill wrote; the true write time was ~07-15. Moot for recovery β€” the Phase-1 writes are additive (individualId only) and reversed by deleting the field.
  • Blast radius: coaching read seam (lib/coaching/sessionsSource.ts), firestore.indexes.json (+1 index), apps-script/{Firestore,SessionSync,Utils}.js (deployed), and tebs-mel data (individualId added to 11 Students + 113 Sessions). Accounting/records server readers (collectionGroup + findSessionRef) unaffected β€” they can now optionally query .where('individualId','==',X). Residual owner step (not blocking done): fire the manual Vercel deploy to ship the Phase 3 web change.

2026-07-15 β€” post-close integrity sweep (owner-requested "nothing left behind")

Read-only, three databases, all clean: - tebs-mel core (all 113 sessions): 113 nested / 0 top-level; 0 phantom parents (top-level + nested); 0 sessions missing individualId; 0 individualIdβ‰ parent-student; 0 legacy collectionGroup('invoice') docs; 15/15 invoice maps valid, 0 duplicate numbers; 0 I-029 loss signatures (payment carries an invoiceNumber but session has no invoice); 0 payment#β‰ invoice# mismatches; 0 orphaned invoiceUpdateLogs. - Share links (aote-system/viewerLinks, kind coaching-invoice): 431/431 resolve to a session that has its invoice; 0 β†’ missing session; 0 β†’ session-without-invoice (the I-029 symptom); 0 expired. Matches the fork agent's post-restore I-029 state β€” no regression from Phase 1/2. - Cross-DB (tebs-erl accounting/transactions/entries gl.coachingInvoices β†’ tebs-mel): 176 tx scanned, 15 distinct coaching invoice numbers referenced, 0 dangling (every reference lands on a real invoice) and 0 unreferenced the other way (all 15 invoices are matched to a bank tx). - Net: bidirectionally consistent β€” tebs-mel 15 invoices ⇄ 431 share links (all resolve) ⇄ tebs-erl 15 referenced (0 dangling). Nothing left behind in sessions, invoice maps, subcollections, share links, or accounting cross-references.

Commit index (append-only Β· Coaching (Diagnostic))

  • 4fe58d5 2026-07-15 β€” feat(T-052): Phase 1 β€” individualId anchor on coaching Students + Sessions (#877)
  • f44da72 2026-07-15 β€” feat(T-052): sync merge-write + web dual-read β€” Phase 2 + Phase 3 (#882)
  • f23c36f 2026-07-15 β€” docs(T-052): close out β€” Phase 2 deployed + verified live, integrity clean (#898)
  • Data migration scripts/backfill-session-individual-id.ts --apply (Firestore writes, not a commit; backup in scripts/migration-backups/, gitignored). Index created via Firestore Admin API (not a commit). Apps Script deployed via owner clasp push (not a repo deploy).