Managed Members — Data Model
Ticket: TKN-382
Status: Specification, revision 2. Not implemented.
Revision 2 supersedes the participants-entity design. See §2 for why it was wrong and what replaced it.
Normative for schema. Every claim about current state carries a path:line citation; anything unverified is marked [INFERENCE].
1. The actual problem
Not “we have no person entity”. We have one. The problem is narrower than revision 1 claimed:
| # | Blocking fact | Citation |
|---|---|---|
| 1 | users.email is varchar(255) NOT NULL UNIQUE. This is the whole blocker. | libs/db/src/lib/schema/users.ts:22 |
| 2 | form_signatures has one identity slot and no signedByUserId. “Guardian signed for child” is unrepresentable. | libs/db/src/lib/schema/forms.ts:305-334 |
| 3 | legal_consents.userId is single-actor with no on-behalf-of column. | libs/db/src/lib/schema/legal.ts:35-37 |
| 4 | audit_events has one actorUserId and actorType ∈ {user, system}. “Guardian X booked for child Y” is unrecordable. | libs/db/src/lib/schema/audit-events.ts:49, enums.ts:848 |
| 5 | Billing keys on membershipId only — payer and member are structurally the same identity. | libs/db/src/lib/schema/payments.ts:271-296,472-497 |
| 6 | No relationship table exists. Guardianship, in any form, is unmodelled. | grep across libs/db/src/lib/schema/ — zero hits |
| 7 | MIN_AGE_YEARS = 13 rejects any birthdate implying age < 13. | libs/shared/src/lib/validation/person.ts:56 |
| 8 | The importer drops emailless rows: if (!email) return 'skipped'. | apps/api/src/import/import.service.ts:629-630 |
2. Decision: no new person entity
Revision 1 proposed a participants table plus a participant_identity view to coalesce it against users. That was wrong, and the review objection was correct.
Why it was wrong
users is already a person table, not an account table. Three pieces of evidence, all in the running codebase:
users.clerkIdis already nullable (users.ts:21). A row that cannot log in is already representable and already exists.- Login-less “shell users” are pervasive and load-bearing.
memberships.service.ts:1587-1601inserts ausersrow with noclerkId, carryingfirstName,lastName,phone,birthDate,gender,emergencyContactName/Phone/Relationship— and attaches apending_invitationmembership to it (:1605). The same pattern appears at:2518. These rows carry form instances (forms.service.ts:2048-2052), legal consents, and their own scrub/tombstone lifecycle (payment-monitoring.service.ts:910-924). Insights has a dedicated join to exclude them from roster counts (insights.service.ts:1704-1708). usersholds person attributes, not auth attributes:firstName,lastName,birthDate,gender,phone,emergencyContact*,nationalIdEncrypted(users.ts:23-38). Authentication is one nullable column.
So the only thing making users an “account” table is the email NOT NULL UNIQUE constraint. A second person table would have duplicated an existing concept, and the coalescing view in revision 1 was a smell: it existed purely to paper over having two person tables. Removing the second table removes the need for the view.
What replaced it
A managed member is a users row that a responsible adult may act for. That is the whole definition, and it is the one an implementer is most likely to get wrong.
Being managed, having an email, and having a login are three orthogonal axes:
| Axis | Column | Meaning |
|---|---|---|
| Can they authenticate? | clerkId null or set | Nothing to do with being managed |
| Are they reachable? | email / phone null or set | A notification channel, not an authority |
| Does someone act for them? | guardianships rows + kind = 'managed' | This is what “managed” means |
kind = 'managed' does not mean “has no email” and does not mean “cannot log in”. All four of these are valid and all four occur:
- Child, no email, no login — the adult books from their own account. A fully valid bookable entity.
- Child with an email, no login — the adult books; the child also receives the confirmation.
- Teen with email and a login — books for themselves, and the adult still books and pays. Guardianship does not end because a login begins.
- Adult with a login and no guardian — today’s normal case.
Case 3 is why claiming a login and ceasing to be managed are two separate acts (§3.9). Conflating them is what forced the earlier draft into treating majority as a single violent flip; separating them yields the graduation overlap band for free.
The cost of this decision, stated honestly: users no longer means “someone who can sign in”, users.email stops being a guaranteed unique business key, and every code path that sends an email or matches on one must handle null. That is a real cost. It is smaller than the cost it avoids, it is compiler-enforceable for reads (§6), and the uniqueness half is replaced deliberately rather than lost (§3.1).
What this collapses
Revision 1’s largest work package disappears entirely.
| Revision 1 required | Revision 2 |
|---|---|
memberships.participantId added, userId made nullable, unique constraint swapped | No change to memberships beyond one additive column. userId stays NOT NULL; unique(userId, organizationId) stays correct, because the child has their own userId. |
member_profiles re-keyed the same way | No change. The child’s profile keys on the child’s userId as it does for any member. |
form_instances.assigneeUserId relaxed to nullable, subjectParticipantId added and backfilled | No change to assigneeUserId. It stays NOT NULL and points at the child. Only signer columns are added. |
legal_consents re-pointed | Additive capacity column only. |
Member semantic search repointed off users.nameEmbedding to a view | No change. The child is a users row, so name search works untouched. |
A participant_identity view, and a single write path to keep two tables from drifting | Deleted. No second table, no drift, no view. |
| 11 migrations | 6 migrations (shipped as one, §5) |
Cross-organization identity — the reason revision 1 rejected “membership-as-participant” — is preserved for free: one child is one users row with one membership per branch, exactly as an adult is today.
Naming
“Participant” is dropped. With no new entity there is nothing to name, and the review objection stands on its own merits.
| Concept | Code | English UI | Hebrew UI |
|---|---|---|---|
| A member managed by an adult | users.kind = 'managed' | Managed member | חשבון מנוהל |
| The adult holding authority | guardianships | Responsible adult | אחראי |
| The authority grant itself | guardianships row | Guardianship | אחראות |
אחראי is adopted over both אפוטרופוס and הורה/מבוגר מאשר. It is warmer than אפוטרופוס, accurate for a grandparent or legal guardian in a way הורה is not, and it matches how the gym law actually frames the role — הורה there is defined to include anyone with lawful custody, which is exactly “the responsible one”. אפוטרופוס is retained in legal documents and the staff record only, where the statutory term is the right one.
3. Schema changes
3.1 users — one constraint relaxed, uniqueness re-established
~ email varchar(255) NULL (was NOT NULL)
- unique(email)
+ uniqueIndex(email) WHERE email IS NOT NULL
+ nationalIdHash varchar(64) NULL -- deterministic HMAC-SHA256
+ uniqueIndex(nationalIdHash) WHERE national_id_hash IS NOT NULL
+ kind userKind NOT NULL DEFAULT 'account' -- 'account' | 'managed'Notes that matter:
- The partial unique index on
emailpreserves the existing invariant for everyone who has one, including the tombstoning path that rewrites an address todeleted+<id>@deleted.taikan.fiton scrub. Tombstones stay unique because they stay non-null. kindis an explicit enum rather than an inferredemail IS NULLtest. A shell user awaiting invitation acceptance also lacks a Clerk identity but is an account-to-be; conflating the two would push invited adults through the guardian flow. The distinction must be stated, not derived — and since email and login are orthogonal tokind(§2), there is nothing to derive it from anyway.birthDatebecomes required forkind = 'managed'— CHECK-enforced. It is the input TKN-383’s age eligibility needs.
Uniqueness cannot depend on email
users.email NOT NULL UNIQUE is today’s dedup mechanism, and findOrCreateFromClerk matches on it (apps/api/src/users/users.service.ts:100-102). Relaxing the constraint removes a business key, not just a validation. Losing it silently would produce duplicate children and mislinked logins, so it is replaced in three deliberate layers:
1. nationalIdHash — the real key. Every Israeli resident receives an immutable מספר זהות at birth registration; the physical document only issues from age 16, and both Arbox and Boostapp already collect it (research.md §1). So a child with no email and no phone is still uniquely identifiable. nationalIdEncrypted already exists but is encrypted and therefore cannot carry a unique index — hence a separate deterministic HMAC-SHA256 column with a partial unique index. The hash is keyed separately from the encryption key and is never reversible to the ID.
2. Soft dedup where there is no ת.ז. Same firstName + lastName + birthDate under the same responsible adult → warn staff, do not block. This is the Arbox duplicate-profile failure mode surfaced as a warning rather than a silent duplicate. Blocking would be wrong: a family can legitimately reach that state after a re-registration, and a hard constraint here would wedge the front desk.
3. A token, never an email, links a login. See §3.9. Email may be absent, or may already belong to a different users row.
The '' hazard. Any normalisation that maps NULL to the empty string — email?.trim().toLowerCase() ?? '' — makes every managed member collide with every other managed member. Two unrelated children become one person. This is a data-integrity bug, not a delivery bug, and it is the single most dangerous consequence of this change. Audited call-site by call-site in §6.
3.2 guardianships — the one genuinely new table
libs/db/src/lib/schema/guardianships.ts
A person↔person capability grant. Global by default (organizationId nullable), per the review decision on Q-06.
| column | type | null | notes |
|---|---|---|---|
id | uuid | no | PK |
subjectUserId | uuid | no | FK → users.id ON DELETE CASCADE. The managed member. |
guardianUserId | uuid | no | FK → users.id. Must have kind = 'account' — authority requires a login. |
organizationId | uuid | yes | NULL = applies in every org the subject belongs to. Non-null = grant scoped to one branch. |
relationship | guardianRelationship | no | parent, step_parent, grandparent, sibling, legal_guardian, institution, other |
status | guardianshipStatus | no | pending, active, revoked, expired |
isPrimary | boolean | no | Exactly one active primary per subject. |
source | guardianshipSource | no | self_asserted, guardian_approved, staff_approved, import |
| 13 capability booleans | boolean | no | See behavior.md §2 |
objectionLodged | boolean | no | Rebuts the Israeli presumption of co-parental consent; escalates consentQuorum for this subject. |
objectionLodgedAt / objectionNote | timestamptz / text | yes | |
accessSuppressedFrom | timestamptz | yes | Suppression, not revocation — see below. |
accessSuppressionReason | suppressionReason | yes | court_order, safeguarding, operator_decision |
accessSuppressionEvidenceRef | text | yes | |
requestedByUserId / approvedByUserId / revokedByUserId | uuid | yes | FK → users.id |
approvedAt / revokedAt / expiresAt | timestamptz | yes | |
attestationText | text | yes | The exact string the adult accepted, stored verbatim. |
attestedAt / attestedIp / attestedUserAgent | timestamptz / varchar(45) / text | yes | Consent receipt. |
createdAt / updatedAt | timestamptz | no |
Constraints:
uniqueIndex('guardianships_global_uq').on(subjectUserId, guardianUserId).where(sql\organization_id IS NULL AND status <> ‘revoked’`)`uniqueIndex('guardianships_scoped_uq').on(subjectUserId, guardianUserId, organizationId).where(sql\organization_id IS NOT NULL AND status <> ‘revoked’`)`uniqueIndex('guardianships_one_primary_uq').on(subjectUserId).where(sql\is_primary AND status = ‘active’`)`- Index
(guardianUserId, status)drives “my household”;(subjectUserId, status)drives “who may act for this member”. subjectUserId <> guardianUserId, enforced in the service.
attestationText is stored verbatim, not as a template-version pointer. iClassPro voids every policy acceptance when a guardian’s name is edited — renaming a person destroys their signed consents. Storing the accepted string on the grant makes that class of bug impossible.
Suppression is not revocation. חוק זכויות החולה ס’16א is direct precedent: where a parent is charged with or convicted of an offence against their child, information shall not be given to that parent. Revocation is visible and self-explanatory, and in a safeguarding context that disclosure is itself the harm. So suppression is enforced in GuardianAccessService only, is invisible to the suppressed adult (their UI shows an ordinary grant and absent data, never “hidden from you”), is visible to staff with reason and evidence, and is never inferred from custody — a non-custodial parent retains access by default.
3.3 Why guardianship is not a membership
The review raised this directly: “guardianship is same level as membership. it’s actually a scoped/restricted membership.”
Half right, and the half that is right is worth acting on. A membership relates a person to an organization. A guardianship relates a person to another person. Different arity, so they cannot be one table: a guardian of three children in one org would need three rows, which unique(userId, organizationId) forbids.
But the intuition that they are the same tier of concept is correct, and it has a concrete consequence: authority must resolve through one service regardless of where it comes from. Role-derived authority (membership) and relationship-derived authority (guardianship) both terminate in a single question:
can(actorUserId, subjectUserId, capability, orgId): Promise<Decision>That is the unification to build. Today authority is checked three inconsistent ways — PermissionsService.enforce, isStaffRole(), and ~45 raw inline role comparisons. Guardianship adds a fourth axis, and adding it as a fourth idiom is how this becomes unreviewable. One resolver, one decorator (WP-04).
3.4 memberships — one additive column
+ participation participationKind NOT NULL DEFAULT 'trains' -- 'trains' | 'guardian_only'That is the entire change. userId stays NOT NULL, unique(userId, organizationId) stays.
participation exists because an adult who only manages a child still needs a membership row to be billed, contacted and audited in the org — without inflating the member counts that drive analytics and platform billing. Every roster count, MRR figure, capacity report and platform-billing seat count must filter participation = 'trains'. Missing one is the most likely silent bug in this ticket. There is precedent for the hazard: SwimTopia instructs customers to create a teen’s volunteer login as a parent-type record specifically “so the teen doesn’t inflate the billable athlete roster count”.
3.5 forms — signer separate from subject
form_instances:
+ signerRequirement signerRequirement NOT NULL DEFAULT 'self' -- 'self' | 'guardian' | 'either'
+ healthArtifactKind healthArtifactKind NULL
+ validFrom timestamptz NULL
+ expiresAt timestamptz NULL
+ consentScope consentScope NULL
+ consentTiming consentTiming NOT NULL DEFAULT 'prior'
form_signatures:
+ signedByUserId uuid NOT NULL FK → users.id
+ signedInCapacity signingCapacity NOT NULL -- 'self' | 'guardian' | 'staff_witnessed'
+ signerRelationship guardianRelationship NULL
+ signerFullName text NOT NULL -- typed by the signer at signing time
+ attestationText text NOT NULLassigneeUserId is unchanged — NOT NULL, pointing at the member the form is about, which for a child is the child’s own row.
This deliberately reopens a door the team deliberately closed. forms.service.ts:3478-3482 rejects any signer who is not the assignee, and the comment at forms.controller.ts:562-566 records why: a bare token let anyone sign as the member, “including a minor consenting on their own behalf.” The replacement is not a relaxation — it replaces “the token is valid” with “authority is proven”:
- A managed member’s form carries
signerRequirement = 'guardian'. There is no login to self-sign with, and self-signing by a minor is never permitted at any age. - Accepted iff (
selfand caller is the assignee) or (guardianand an active, unsuppressed guardianship withcanSignFormsexists at submit time, not at render time). - Backfill every historical signature as
signedInCapacity = 'self'— truthfully, because that is what it was.
Health artefact expiry is statutory, not a product setting. A health declaration refreshes every 24 months, or 12 where a medical certificate was triggered; a competing athlete needs a seasonal questionnaire and an annual physical. member_profiles.medicalCert is a bare boolean (member-profiles.ts:43) and cannot carry an expiry date. The forms gate in bookings.service.book() (bookings.service.ts:103-108) must consider expiresAt, not just status = 'signed'.
3.6 payments — separate the payer
subscriptions:
+ payerUserId uuid NULL FK → users.id -- NULL = the member pays for themselves
+ invoiceName text NULL -- tax-invoice name when it differs from the payer's
member_payment_methods:
+ ownerUserId uuid NULL FK → users.id
+ fallbackLiableUserId uuid NULL FK → users.id -- third-party payer defaultmembershipId stays the entitlement holder; payerUserId is the liable party. One stored card funds every child’s subscription without duplicating the token per membership.
invoiceName is Q-15, confirmed. Arbox already ships it and an accountant will ask.
fallbackLiableUserId comes from SafeSplash’s third-party payer agreement, where liability reverts to the original enrolling customer if the third party defaults. Without it, a grandparent’s failed card leaves the operator with a debt and nobody to pursue.
3.7 bookings, audit_events
bookings:
+ bookedByUserId uuid NULL FK → users.id
+ cancelledByUserId uuid NULL FK → users.id
audit_events:
+ subjectUserId uuid NULL FK → users.id
+ actorCapacity actorCapacity NOT NULL DEFAULT 'self' -- 'self' | 'guardian' | 'staff' | 'system'
~ auditCategory enum: + 'guardianship'Booking subject remains membershipId, so entitlement, capacity and duplicate-prevention stay per member and two siblings are two seats. The actor columns answer “who did this”, which is the question that settles every co-parent dispute. Left NULL for history — we genuinely do not know, and backfilling = membership.userId would fabricate an audit record.
audit_events gains an index on (organizationId, subjectUserId, createdAt). Without it the log cannot answer “show me everything that happened to this child”, which is the first thing a complaining parent and a regulator both ask for. Guardian access is also a statutory right of the child, exercised by the adult (חוק הגנת הפרטיות ס’13(א)) — so the log must record “guardian X exercised child Y’s access right”, not “guardian X viewed their own data”.
3.8 Not changed
Stated explicitly so nobody re-adds them: memberships.userId, memberships unique constraint, member_profiles, form_instances.assigneeUserId, legal_consents.userId (capacity column only), users.nameEmbedding and member search, attachments, conversations, notification_prefs.
Messaging is out of phase 0 entirely (review decision on Q-05), so conversations.subjectUserId moves to a later phase. Notification routing is still required — that is fan-out, not messaging.
3.9 Granting a login later — the graduation path
A managed member without a Clerk identity cannot authenticate, and for the common case — a young child — that is the intended security property rather than an oversight: there is no credential to steal, no session to hijack and no password-reset flow to phish. Fizikal’s documented account-takeover across “dozens of large Israeli gyms” was exactly credential-based compromise of member accounts, and a member who cannot log in cannot be compromised that way. But note the axes in §2: managed does not imply credential-less. A managed member may hold a login, and keep their guardianship.
It is also not a dead end, and this is where revision 2 is better than revision 1 rather than merely cheaper.
Granting a login is an UPDATE to the same row:
UPDATE users SET clerk_id = $1, email = $2, kind = 'account' WHERE id = $3;Same users.id. Every booking, attendance record, signed waiver, body metric, subscription and audit row already points at it and keeps pointing at it. Nothing migrates.
The mechanism already exists and is production-proven. UsersService.findOrCreateFromClerk (apps/api/src/users/users.service.ts:99-128) does precisely this today for imported members: it finds a users row with clerkId IS NULL, attaches the Clerk identity in place, preserves the row id, and backfills name from Clerk only where the import left it null. We are reusing a path that already runs on every imported member’s first sign-in.
Compare revision 1: a participants row plus a newly created users row means two identity rows per human, permanently, with historical activity hanging off one and the login off the other. Revision 2 has one row that gains a credential.
This is also the industry shape, not an invention — pattern P2 in research.md. Google mints a child account with a synthetic non-deliverable identifier and documents that it can later be “upgraded” to a real Gmail and password without changing the account. Nintendo requires a dependent to register an email before supervised status can be removed: a contact channel as the precondition of independence.
Three things the simple story glosses over
1. Email cannot be the match key, because it may be absent. findOrCreateFromClerk links by eq(users.email, email) AND clerkId IS NULL (users.service.ts:100-102). A managed member may well have an email — but may equally have none, and the link path cannot depend on one. Claiming therefore needs a token that names the row: the adult issues an invitation, the token carries the users.id, and the Clerk identity attaches to that row specifically. Email matching must not be the mechanism even when an email happens to exist.
2. The email may already belong to someone else. If the member’s address already exists on a different users row — they hold their own account at another gym — findOrCreateFromClerk would find that row first, attach the Clerk identity to it, and leave the managed row orphaned with all the member’s history stranded on it. The token must take precedence over email matching, and a genuine collision is a merge, not an update. Recorded as R-20.
3. Getting a login does not end guardianship. The two are separate acts on separate columns: attaching clerkId grants authentication; revoking the guardianships rows ends authority. A member can hold both at once, which is exactly case 3 in §2 and what makes the graduation overlap possible — the young member books for themselves while the adult retains payer and emergency-contact rights. Whoever builds the claim flow must not bundle the two, and kind should flip to 'account' only when the last guardianship is revoked, not when a login is attached.
None of these is an architectural problem; all three are requirements on whichever package builds the claim flow. That flow is out of phase 0 by review decision, so they are specified here rather than built now — but users.email’s partial unique index lands in phase 0, so the collision in point 2 is representable from day one and must not be discovered later.
4. New enums
libs/db/src/lib/schema/enums.ts, following the existing style.
| enum | values |
|---|---|
userKind | account, managed |
participationKind | trains, guardian_only |
guardianRelationship | parent, step_parent, grandparent, sibling, legal_guardian, institution, other |
guardianshipStatus | pending, active, revoked, expired |
guardianshipSource | self_asserted, guardian_approved, staff_approved, import |
suppressionReason | court_order, safeguarding, operator_decision |
consentQuorum | single_guardian, all_active_guardians |
consentScope | single_act, act_class |
consentTiming | prior, ratified |
signerRequirement | self, guardian, either |
signingCapacity | self, guardian, staff_witnessed |
actorCapacity | self, guardian, staff, system |
healthArtifactKind | health_declaration, medical_certificate, seasonal_clearance |
assertionMethod | self_declared, guardian_declared, staff_entered, payment_confirmed, document_verified |
messagePurpose | transactional, promotional |
auditCategory | append guardianship |
emergencyContactRelationship on users/invitations stays free-text varchar(100) (users.ts:35-37). It is a different concept from guardianRelationship and the two must not be conflated.
5. Migration sequence
Planned as six migrations, down from eleven; shipped as one, libs/db/drizzle/0168_managed_members.sql, squashed before merge. Drizzle generate → review SQL → migrate. Never db:push. No step leaves the running application broken. The file runs in the migrator’s single transaction: the only ALTER TYPE … ADD VALUE statements (audit_category + guardianship, guardianship_source + staff_enrolled) add values no later statement in the file uses.
In order, as the file runs:
| Part | Content | Safety |
|---|---|---|
| Enums | All new enums; append guardianship to auditCategory. | Type creation only. |
users | Make email nullable; set existing empty-string emails to NULL (old phone-only sign-ups) before the partial unique index users_email_unique_idx (email IS NOT NULL) replaces unique(email); add kind default 'account', national_id_hash with its partial unique index, and the managed-needs-birthdate CHECK. | kind defaults, so no backfill. The empty-string step is load-bearing: two '' rows would violate the new index. Not reversible once NULL emails exist; see rollout-runbook.md. |
guardianships | The table, its three partial unique indexes and lookup indexes; the suppression columns. | Additive; nothing references it yet. |
memberships | participation default 'trains'. | Additive, defaulted, no backfill. |
forms | Signer columns on form_signatures; signerRequirement and the health-artefact columns on form_instances. Backfill signedByUserId = assigneeUserId, signedInCapacity = 'self', signerFullName from users, attestationText = the historical self-signing statement. | Every historical signature is truthfully relabelled as a self-signature. |
| Payer, actor, audit | subscriptions.payerUserId + invoiceName; member_payment_methods.ownerUserId + fallbackLiableUserId; bookings.bookedByUserId + cancelledByUserId; audit_events.subjectUserId + actorCapacity + index. | All additive and nullable. |
| Import | The reconciliation queue table (import_reconciliation_reason). | Additive. |
The adult-member invariant
TKN-382 requires “safe migration of existing adult memberships.” Revision 2 makes this nearly trivial, because no existing row is re-keyed and no existing column changes meaning. The relaxation is users.email becoming nullable; the only existing rows it touches are empty-string emails, which become NULL.
How it was proven in phase 1. The dedicated adult-migration-parity.int.spec.ts planned here was not written. What stands in its place: the strict-null type-check gate (scripts/strict-null-gate.sh, no new ! or ?? ''), every existing API and web suite passing on the migrated schema (unit, integration, e2e), drizzle-kit generate reporting no drift, and a fresh database migrated through 0168 producing a schema identical to the test database’s.
6. The one real risk, and its safety net
Every code path that reads users.email must now handle null. Email sends, Clerk sync, exports, the importer’s matching logic, lead conversion, receipts.
The mitigation is the same mechanism revision 1 relied on, and it is cheaper here because one column changes instead of five tables: making email nullable in the Drizzle schema regenerates the row type as email: string | null, so npx nx build api enumerates every unsafe read as a compile error. The sweep is mechanical. WP-02 is gated on a clean type-check plus the parity spec, not on reviewer diligence.
Two rules for working that list:
- Never silence a null with
!or?? ''. An empty-string email is a silent delivery failure or, worse, a cross-member leak if it is used as a matching key. - A managed member with no email is a legitimate terminal state for a send, not an error. Skip and record; do not throw. Notification fan-out to guardians is the correct path (WP-07).
7. Shared types
libs/shared/src/lib/schemas/guardianship.schema.ts, barrel-exported from libs/shared/src/index.ts, following conversation.schema.ts: as const value tuples with derived types, entity schemas whose nullability mirrors the DB exactly, .extend() for detail-vs-list variants, *Input/*Response naming, .refine() for cross-field invariants.
GuardianCapabilities is the single TypeScript source for the thirteen capability booleans — the API resolver, the web UI and the tests all import it. It must not be re-declared anywhere.
8. What the swim-school case still needs and this does not provide
Recorded so the plan does not look more complete than it is.
| Need | Disposition |
|---|---|
| Structured medical facts (asthma, epilepsy, non-swimmer) and a printable roster summary | Out of scope. The forms engine can capture it as answers; a queryable summary needs its own ticket. canViewHealth exists now so the permission is ready when the data is. |
| Assessed level and recurring group placement | TKN-383. This ticket guarantees birthDate is non-null for managed members, which is the input an age-band rule needs. |
| Authorised pickup and escort release | TKN-388. Seam: canAuthorizeEscorts. No product in the category has built this. |
| Make-up credit policy engine | TKN-385. iClassPro’s expiring Makeup Token is the reference; the pilot customer’s own policy is one per calendar month, completed within two weeks, non-transferable between siblings. |
| Pooled family quota across siblings | Out of phase 0. Quotas are per-member, which is the correct default. Boostapp’s pooled-versus-per-head switch is the reference if it is ever wanted. |
| Consolidated family invoice, split billing across households | Excluded from phase 0 by review decision. DanceStudio-Pro’s percentage-split autopay is the reference shape. |