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/Sessionsis 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 frozenaccountfield), seen in lib/sessionStats.ts:20 (account = sessionDoc.sessionName) + SessionsTab/useBillingInfo. Some cache-invalidation paths also carrystudentAbbr(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
individualIdanchor 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
individualIdat 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
accountmatch 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.
Scope decision β A (link-only), per R1 β owner-confirmed 2026-07-15¶
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βcanonicalAbbreviationoverabbreviation+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) andKTβ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
individualIdyet β 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 theinvoicemap (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, scansaote-system/individuals). - Dual-read seam:
lib/coaching/sessionsSource.ts:64getStudentSessionDocs(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.jshandleCalendarItem_(124) βresolveAbbrByAccount_(apps-script/Utils.js:64) β writesStudents/{abbr}/Sessions/{id}(243); noindividualId. - 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.
Plan (T-081/T-048 playbook: snapshot β propose-link β additive stamp β dual-read β flip β retire name-match)¶
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β stampedindividualIdon 11/11 Students + 113/113 Sessions (additiveset(...,{merge:true}); backup-first; PITR recovery point2026-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β aSessions.individualIdfieldOverride with aCOLLECTION_GROUPscope socollectionGroup('Sessions').where('individualId','==',X)resolves (it currentlyFAILED_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 preferindividualId, keep name-match fallback) and Phase 2 (Apps ScripthandleCalendarItem_stampsindividualIdfromStudents/{abbr}.individualIdat creation β ownerclaspdeploy).
2026-07-15 β index CORRECTION: created via Admin API (not the manual deploy)¶
- The owner's
firebase deploy --only firestore:indexesran from a checkout behindmain, so it deployed the oldfirestore.indexes.json(noindividualIdoverride) β the index never got created. Instead I created it directly from the cloud env via the Firestore Admin API (FirestoreAdminClient.updateField, adding theSessions.individualIdsingle-field override withCOLLECTION/COLLECTION_GROUPscopes). 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 onmainstill matches, so future deploys are consistent.)
2026-07-15 β Phase 3 SHIPPED (web dual-read seam) + a sync bug discovered¶
lib/coaching/sessionsSource.tsgetStudentSessionDocsnow reads the canonical nested sessions and, for the top-level fallback, unions two keys: the T-052individualIdanchor (read off the nested docs) and the legacysessionName==accountmatch β merged by id, nested wins. SurfacesindividualIdonCoachingSessionDoc. Behavior-preserving (top-level empty β identical result set); verified live via SA mimic (Nan 22/22, KT 50/50, Geet 1/1).tsc --noEmitclean. 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 noindividualId, so thesessionNamematch 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.jswriteFirestoreDocissues a Firestore REST PATCH with noupdateMask, which REPLACES the whole document (proven on a throwaway doc β a siblinginvoicemap was dropped). So the calendar sync wipes a session'sinvoicemap (and would wipeindividualId) 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 preserveinvoice/individualId/createdAtβ then stampindividualIdfromStudents/{abbr}.individualId. Both are deployed-Apps-Script changes β ownerclaspdeploy. Flagged to owner.
2026-07-15 β Phase 2 IMPLEMENTED (Apps Script; awaits owner clasp deploy)¶
apps-script/Firestore.jsβ newmergeFirestoreDoc(path, data): PATCH with anupdateMaskof 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:PATCHmechanism: masked write preserves a siblinginvoicemap; unmasked replaces it).writeFirestoreDockept (+ a β doc-comment) for the history/meta writes that intend a full body.apps-script/SessionSync.js: both session-doc writes now usemergeFirestoreDocβ the main upsert (was line 245) and the cancelled-branchsessionType:'Cancelled'write (was 161). The two non-session writes (appointmentHistory sub-doc @403, coaching/meta @474) staywriteFirestoreDoc(fresh doc / sync-owned signal). This fixes the latent invoice-wipe regardless of T-052.individualIdstamp at creation:handleCalendarItem_resolvesresolveIndividualIdByAbbr_(abbr)(new inapps-script/Utils.jsβ readsStudents/{abbr}.individualId, per-run cached) and stampsdata.individualIdwhen 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 viaclasp push(owner's Google login). Handoff test:clasp pushfromapps-script/, runauditAllEvents, confirm (a) a re-synced session keeps itsinvoicemap, (b) new sessions carryindividualId. - Deferred to after this deploy proves out: retire the
sessionName==accountread fallback ingetStudentSessionDocs(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 thatdone= merged tomain(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,individualIdstamp,resolveIndividualIdByAbbr_). (I could NOT deploy via the env-var SA: the Apps Script APIupdateContent/scripts.runneed 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'sclasp push.) - Live proof of the merge-write fix: the
auditAllEventsrun re-wrote 7 sessions (updatedAtβtoday); 4 of them carry invoice maps and every invoice survived the re-write. Under the old full-replacewriteFirestoreDocthose 4 invoices would have been wiped. Post-run: 15/15 invoice maps intact (0 lost),individualId113/113, 0 top-level, no dupes. - Outcome vs. plan (all delivered): (1)
individualIdstamped on 11 Students + 113 Sessions [live]; (2)Sessions.individualIdcollection-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 onindividualId[mergedf44da72, ships next Vercel deploy]; (4) calendar sync merge-writes + stampsindividualIdat 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, noindividualId) when it can't resolve a student, so thesessionName==accountarm stays as the edge-case link for unattributed sessions.individualIdis 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-09while real time was2026-07-15, confirmed by the Firestore serverupdatedAt). 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 (individualIdonly) 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), andtebs-meldata (individualIdadded 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))¶
4fe58d52026-07-15 β feat(T-052): Phase 1 β individualId anchor on coaching Students + Sessions (#877)f44da722026-07-15 β feat(T-052): sync merge-write + web dual-read β Phase 2 + Phase 3 (#882)f23c36f2026-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 inscripts/migration-backups/, gitignored). Index created via Firestore Admin API (not a commit). Apps Script deployed via ownerclasp push(not a repo deploy).