Managed Members — Behavior Specification
Ticket: TKN-382
Status: Specification, revision 2. Not implemented.
Companion: data-model.md is normative for schema; this file is normative for behavior. Revision 2 drops revision 1’s separate person table: a managed member is an ordinary users row (data-model.md §2).
1. Vocabulary
The product needs five words that today all collapse into “member”. Every one of these is a distinct role that a distinct human can hold.
| Term | Definition | Identity |
|---|---|---|
| Managed member | A person who trains but cannot sign in: a child or dependent. The subject of bookings, attendance, entitlement, level, results. | users row with kind = 'managed', email NULL, no Clerk identity. Holds an ordinary membership. |
| Account holder | The person who holds a login. | users row with kind = 'account' + Clerk identity. |
| Guardian | An adult authorised to act for a managed member they are not. Authority is explicit, capability-scoped, revocable and audited. | guardianships row linking guardianUserId to subjectUserId, both users.id. |
| Payer | The party financially liable for a member’s subscription. | subscriptions.payerUserId. |
| Actor | The human who performed a specific action. Recorded on every consequential write. | bookings.bookedByUserId, audit_events.actorUserId + actorCapacity. |
A managed member is not a new kind of entity. users already holds login-less rows: memberships.service.ts:1587-1601 inserts a “shell user” with no clerkId for every pending invitation. kind is what separates a managed member from an invited adult who is simply an account-to-be, and it is stated rather than inferred from email IS NULL (data-model.md §3.1). A managed member trains, accrues attendance and holds entitlement exactly as any member does; the only difference is that an adult acts for them.
Words we deliberately do not use
- “Sub-account” — implies subordinate identity. A child’s attendance record is not a subset of a parent’s; it is their own. The word also invites the incumbent mistake of nesting the child’s history under the parent’s, which is what breaks at age 18.
- “Family account” — implies a billable aggregate we are deliberately not building (see
data-model.md§8). - “Dependent” — accurate for tax, wrong for a 17-year-old swimmer, and untranslatable into natural Hebrew for this context.
- “Participant” — revision 1’s name for the trainee and for the table it proposed. Review rejected the table, and the word went with it: with no new entity there is nothing to name, and a member who is managed is still a member.
Hebrew is the default locale, so the Hebrew term is the primary one:
| Concept | he | en | ru |
|---|---|---|---|
| Managed member | חשבון מנוהל | Managed member | Управляемый аккаунт |
| Guardian | אחראי | Responsible adult | Ответственный взрослый |
| Guardianship | אחראות | Guardianship | Опека |
| Household | משפחה | Household | Семья |
Decision (Q-01): אחראי. It is warmer than אפוטרופוס, which reads heavy and clinical to a parent booking a swim lesson; it is accurate for a grandparent or legal guardian in a way הורה is not; and it matches how the gym law frames the role, since הורה there is defined to include anyone with lawful custody — which is exactly “the responsible one”. אפוטרופוס is kept for legal documents and the staff record, where the statutory term is right. “Guardian” remains the word in code.
2. The capability model
Authority is thirteen explicit booleans on guardianships, not a role. A role would force us to guess which bundle a divorced co-parent needs; booleans let the primary guardian say exactly.
| Capability | Grants | Default (primary) | Default (secondary) |
|---|---|---|---|
canViewSchedule | See the member’s bookings and the class schedule in their context | ✓ | ✓ |
canBook | Book and cancel sessions for the member | ✓ | ✓ |
canManageSubscription | Buy, change, pause, cancel a plan | ✓ | ✗ |
canPay | Attach a payment method and be charged | ✓ | ✗ |
canSignForms | Sign waivers and health declarations on the member’s behalf | ✓ | ✗ |
canViewAttendance | See attendance history and absences | ✓ | ✓ |
canViewHealth | See body metrics, medical answers, injury notes | ✓ | ✗ |
canViewProgress | See results, PRs, goals, assessed level | ✓ | ✓ |
canMessageStaff | Open and read threads about the member (no effect in phase 0 — §4.6) | ✓ | ✓ |
canReceiveNotifications | Receive reminders, waitlist promotions, cancellations | ✓ | ✓ |
canManageProfile | Edit contact details: phone, emergency contact, gender. Name, email and photo are staff-only (403 staff_only_field) | ✓ | ✗ |
canManageGuardians | Invite and revoke other guardians | ✓ | ✗ |
canAuthorizeEscorts | Nominate who may collect the member (seam for TKN-388) | ✓ | ✗ |
Invariants
- Exactly one active primary guardian per managed member. DB-enforced by a partial unique index. A managed member with no active primary is an incident state, not a valid one — see §7.4.
- The primary guardian’s capabilities cannot be reduced below
canManageGuardians+canSignForms. Someone must always be able to answer for the child. Enforced inGuardianshipsService. - Nobody is their own guardian.
guardianUserId <> subjectUserId. - A guardian must hold a login.
guardianUserIdmust be akind = 'account'user. Authority is exercised, and exercise requires authentication. - Capability grants never exceed the granter’s own. A secondary guardian with
canManageGuardianscannot mint a third guardian withcanPayif they lack it themselves. - Staff capability is orthogonal. Staff act under
PERMISSION_MATRIX, never by acquiring a guardianship. A coach does not become a guardian. - A guardian must be at least 21. Enforced by
GuardianshipsServiceon the grant, against the guardian’susers.birthDate, so it applies identically to a parent, a grandparent and a sibling — the check is on the adult, never on the relationship. This is stricter than the market: iClassPro only asks a secondary user to confirm 18+ (research.md§2). It is defensible because the grant authorises signing a health declaration for someone else’s child, and the one-guardian-suffices rule in Israeli gym law (§5.1a) means that signature may be the only one collected. The consequence, stated rather than discovered: an 18-to-20-year-old parent of their own child cannot self-assert. Their grant is created through staff approval (source = 'staff_approved') with a recorded reason on theguardianshipaudit event — an override, not a hard block, because refusing a parent authority over their own child is not a position the platform can hold.
Why not a role enum
Three named bundles (full, booking_only, viewer) were considered and rejected: the divorced-parent case needs canBook + canViewAttendance + canReceiveNotifications but explicitly not canPay or canManageGuardians, and a grandparent doing school pickup needs canViewSchedule + canAuthorizeEscorts and nothing else. Neither is expressible as a tier. The UI still presents three presets that write the booleans — the preset is a copywriting device, not a data model.
3. Guardianship establishment
Chosen policy: staff enrol every child; the first adult confirms in the app; everyone after is approved. (Product decision 2026-10-07, superseding the earlier “self-asserted for the creator” policy: parents cannot add children themselves.)
3.1 First guardian — staff-enrolled, confirmed by the adult, attested, audited
Only staff create a managed member. The adult the child is linked to still makes the legal statement themselves — staff never click through an attestation on a parent’s behalf:
- Staff open Add child (from Members, or from an adult’s record, which pre-fills them) and enter the child’s details, including the birth date a managed member requires (
data-model.md§3.1). - Staff pick the responsible adult: an existing person in the org — a training member or a guardian-only adult — or a new email. A new email creates (or reuses) an adult shell account the person claims when they sign up with that address.
guardianshipsis written withsubjectUserId= the child’s newusers.id,guardianUserId= the adult,source='staff_enrolled',status='pending',isPrimary=true,expiresAt=NULL. The adult is emailed a link to/family/accept/<grantId>?org=<orgId>, and the link also appears as a pending item on their family hub. Staff can resend it from the child’s record.- Clerk sign-up is invitation-only. When the address has no login yet, the email button is a Clerk invitation (
notify: false) whose redirect is that accept link: the adult signs up inline on the accept page from__clerk_ticket, then returns to the same link. An address that already has a login gets the bare link, because Clerk would turn the ticket into a sign-in ticket for that account. - The ticket expires with the grant (an invite’s 14 days); a grant with no expiry keeps Clerk’s default 30 days, and a resend mints a fresh ticket. If Clerk cannot be read, the bare link goes out. If the address is known to have no login and no ticket can be minted, nothing is sent: enrolment reports
invitationSent: falseand resend answers 503invitation_not_sent. /familyis not behind Clerk’s middleware, so the accept page checks auth itself. Signed out with no ticket, it sends the visitor to/sign-in?redirect_url=<the accept link>.
- Clerk sign-up is invitation-only. When the address has no login yet, the email button is a Clerk invitation (
- Until the adult confirms, the link authorises nothing: nobody can book, sign or pay for the child.
- The adult confirms in the app: they type their name (prefilled from their profile,
signerNameon the pending detail) and accept a verbatim attestation, rendered by the server and stored in full on the grant (attestationText,attestedAt/Ip/UserAgent). The 21+ rule is checked from their birth date, not a checkbox: when their profile has none (birthDateRequired), the form asks for it and the accept request carries it. A date that passes is saved to the profile in the same transaction; a refused one is not kept, so a typo can be corrected. A stored birth date is never overridden by the request:“I confirm I am the parent or legal guardian of [name], that I am authorised to enroll them and to sign documents on their behalf, and that the information I have provided is accurate. Taikan and [organization] rely on this confirmation.” The grant becomes
active. An adult who does not train becomes aguardian_onlymember of the org at this moment. - They then finish the child’s setup (health declaration, waiver and youth parental consent, plan, first class). If the org has a
parental_consent_youthtemplate, it is issued to every managed member and required before the first booking (§4.3). audit_eventsrows record the staff creation (actorCapacity='staff') and the adult’s acceptance (actorCapacity='guardian'), withsubjectUserId= the child. If the adult declines, staff get a high-priority task (§7.4).
The attested grant is the consent receipt. It is the record that matters when a non-custodial parent calls the operator.
3.2 Additional guardians — approved, never self-asserted
An adult may not unilaterally claim authority over a child who already has a primary guardian. That is the abuse vector, and it is cheap to close.
Two routes, both ending in an active grant:
Route A — invited by the primary guardian (the common case)
Primary enters name + email or phone → guardianships row with status='pending', source='guardian_approved', capabilities preselected by the primary → invitation sent → invitee signs in (Clerk) → accepts the attestation → status='active'.
Route B — requested by the adult, approved by staff (the recovery case: separated parents who do not speak)
Adult finds the child in the org and requests access → status='pending', source='staff_approved', requestedByUserId set → staff decide, with the existing primary notified → approve writes approvedByUserId/approvedAt. Staff are the arbiter because they can see the enrollment paperwork and the platform cannot.
Route B is the sharp one. Staff are being asked to make a determination about family authority. The UI must therefore (a) never present it as routine, (b) show what the requester asserted verbatim, (c) show that the primary guardian was notified and whether they responded, (d) record the deciding staff member. It is a decision surface, not an approval button — see design-spec.md.
Phase 1: both routes exist in the API (POST members/:memberUserId/guardianships/invite, POST members/:subjectUserId/guardianships/request, POST guardianships/:id/approve / reject, apps/api/src/guardianships/guardianships.controller.ts:151-222), as do capability edits, revoke, make-primary, objection and suppression (:233-295). No screen calls them yet (WP-15): nobody can add a second adult from the apps or the dashboard.
3.3 Staff-created managed members
This is now the only way a child enters the system other than import; see §3.1. Staff decide who the child is linked to; the adult attests. A desk registration that creates a child with no adult is no longer possible.
3.4 Import
Before phase 1, the importer dropped every emailless row, which is why a swim school could not migrate to Taikan at all. Delivered by TKN-406 (WP-11), when the flag is on for the org (apps/api/src/import/import.service.ts:793-806; with it off, rows behave as before):
- Row with no email → managed member (
users.kind='managed'), and any guardianship created for it carriessource='import'. Note the converse does not hold: a row with an email may still be a managed member, so an email column is not evidence of an adult. Age and the presence of a guardian column decide. - Guardian resolved by matching a
parentEmail/guardianEmail/parentPhonecolumn against existing users, creating the guardian user if needed. - Arbox
isChild→kind='managed'. - No guardian resolvable → the managed member is created with no guardianship, which is the orphaned incident state (§7.4), and the row surfaces in a staff reconciliation queue. Never silently dropped, never auto-linked on a surname guess.
- No birth date → the row cannot become a managed member (the CHECK in
data-model.md§3.1) and goes to the same queue as-is rather than being dropped.
Surname-based guessing is explicitly forbidden. Two unrelated Levis in one org would link a stranger to a child.
Phase 1: a row is managed when its isChild column is truthy or it has no email. Unresolvable rows go to the reconciliation queue with reason no_guardian, no_birth_date or guardian_ambiguous (import.service.ts:1367-1371), readable and resolvable through GET/POST import/jobs/:jobId/reconciliation… (import.controller.ts:185,201). There is no dashboard screen for the queue yet, and the Arbox direct sync does not create children: only the CSV import does.
3.5 Product flows (phase 1)
Every flow starts the same way: staff enrol, the adult confirms. The child never gets a login, an email or a push.
A young child joins, with a parent who is new to the gym
| Who | What happens |
|---|---|
| Staff | Members → Add child: name, birth date, optional ID number and gender, the relationship, and Invite by email with the parent’s address. The child is created as a member; the record shows the parent as waiting to confirm, with Resend. Staff may give the child a plan now or later. |
| Parent | Gets the staff-enrolment email, signs up with that address and lands on /family/accept/<grantId>. Accepts the attestation with their name (prefilled) and, if their profile has none, their birth date (21+ is checked on it). Becomes a guardian_only member (not counted on the roster) and the app opens on the child (no “myself” view). The setup steps follow: health declaration, waiver and youth consent, plan, first class. |
| Child | No login. Books, attends and holds a plan as any member. If the org has a parental_consent_youth template, the parent must sign it before the first booking. |
If that parent later buys a plan for themselves, they become trains (§4.1) once that plan is live (paid, free, or a completed presale), not when its checkout opens, and get a “myself” view next to the child.
An existing member enrols their child
| Who | What happens |
|---|---|
| Staff | The parent’s record → Add child (the parent is pre-filled), or Add child and search for the parent. Afterwards both records show the household section: the child under the parent, the parent under the child. |
| Parent | The same email, plus a pending item under Profile → Family. After confirming, a switcher appears: “myself” plus each child (§4.7). Their own plan and the child’s are separate; the child’s receipts go to them. |
| Child | As above. |
A teen with their own email, paid for by a parent
| Who | What happens |
|---|---|
| Staff | Enrol the teen with Add child, naming the parent. Add child doesn’t take the teen’s email; staff can add it afterwards on the teen’s record. Only staff set a child’s name and email (§4.5). |
| Parent | Manages and pays as in the flows above. Can edit the teen’s phone, emergency contact and gender. |
| Teen | No login of their own in phase 1 (the claim flow, §5.2, is not built). Signing up with the address on their record is refused with “ask your gym to link your account” (§5.2a). A teen who signed up on their own before being enrolled is an ordinary adult account: no paying parent can be attached to it. |
4. Coverage: the six domains
4.1 Memberships
- A managed member gets an ordinary
membershipsrow keyed on their ownuserId, withparticipation='trains'androle='member'. There is no schema change tomembershipsbeyond the additiveparticipationcolumn:userIdstaysNOT NULLandunique(userId, organizationId)stays correct, because the child has their ownuserId(data-model.md§3.4). Entitlement, attendance and capacity therefore attach to the child with no change to the booking engine (bookings.membershipId,libs/db/src/lib/schema/scheduling.ts:250,270). - A guardian who does not train gets a membership with
participation='guardian_only'. They exist in the org — billable, auditable, reachable — without inflating member counts. Every roster count, MRR figure, capacity report and platform-billing seat count filtersparticipation='trains'. Missing one of those filters is the most likely silent bug in this ticket; it is a named QA item in TKN-405 (WP-10). - A
guardian_onlyadult whose own plan goes live becomesparticipation='trains'in the same transaction: they now train, so they count, appear on the roster and can book. Live means paid, free, assigned by staff, or a completed presale sale or dated start (activeorscheduled). Opening a paid checkout is not enough: thependingsubscription it creates changes nothing, so an adult who abandons checkout staysguardian_only; the promotion happens when the payment completes (any provider, including Cardcom return verification), when the presale card is tokenised, or when staff record cash or an external payment against the pending plan. Paying for a child’s plan changes nothing about the payer’s own membership. - The staff Members list’s People filter — a select beside the other filters, with Training members (the default) and Responsible adults — sends
responsibleAdults=truefor the second option and lists every adult with an active or pending link to a child of the org, whether they also train here or hold only aguardian_onlyseat; revoked links do not count. - One child, two branch organizations → one
usersrow, twomembershipsrows, one global guardianship — exactly as an adult with two branches is modelled today. This is what keeps TKN-386 open. - Suspending a guardian’s membership does not suspend their children’s. The reverse is also true. Statuses are per membership, and a family is not a unit of suspension.
4.2 Subscriptions and billing
Shape: per-child subscription, one payment method.
- Each child holds their own
subscriptionsrow:membershipId= child (entitlement holder),payerUserId= guardian (liable party). - One stored card, owned by the guardian (
memberPaymentMethods.ownerUserId), funds every child’s subscription. No token duplication per child. - Per-child start, pause, cancel and proration all work because each subscription is independent.
- Receipts, invoices and dunning go to the payer. Attendance and entitlement stay with the child. Where the tax invoice must carry a different name from the payer’s,
subscriptions.invoiceNameholds it (Q-15).
Plan change and cancellation. Plan change and proration need no new logic: they are already keyed on membershipId, and the child’s subscription hangs off the child’s membership. The wall is ownership. requireMemberOwnedSubscription (apps/api/src/subscriptions/subscriptions.service.ts:4588) throws when sub.membership.userId !== membership.userId — a guardian acting on a child’s subscription fails it by construction. It must widen to accept a caller who holds an active, unsuppressed guardianship with canManageSubscription over the subscription’s member, and must keep its existing 404-versus-403 masking. The cancellation window and late-cancellation fees charge the payer, never the child.
Quotas are per child. Daily, weekly and monthly booking limits are computed per subscription inside the booking’s SERIALIZABLE transaction (apps/api/src/bookings/bookings.service.ts:376-505), and each child holds their own subscription on their own membership. So each child has their own allowance and siblings never consume each other’s. That is the correct default and it needs no new code — only a test that proves it (TKN-401 (WP-06)). Pooled family quota is deferred; Boostapp’s pooled-versus-per-head switch is the reference if it is ever wanted.
The consequence that must be designed, not hidden: when the guardian’s card fails, the child is blocked from booking, because a subscription in debt status rejects the booking (apps/api/src/bookings/bookings.service.ts:139-155). The child’s screen must say “Booking is paused because payment for this month didn’t go through” with the action routed to the payer — never render a nine-year-old as being in debt.
Excluded from phase 0 by review decision: split billing across two households (Q-02). It is the shape operators ask for and it is genuinely hard — partial payment failure, who sees whose card, two dunning tracks against one entitlement. DanceStudio-Pro’s percentage split of Auto-Pay is the reference shape when it comes. Consolidated single invoice and sibling discounts are deferred with it.
Credits after revocation (Q-08, decided). A guardian buys a 10-session pack for a child, then their guardianship is revoked. Entitlement stays with the child, so the child keeps the credits and the ex-guardian is out of pocket. That is correct for the child and commercially explosive for the operator, so the operator gets a refund/transfer tool, and the ex-guardian is told plainly at revocation time what happens to what they paid for. This must be on the revocation confirmation screen.
4.3 Forms, waivers and health declarations
This is the domain with the highest legal load and the one where the codebase has an explicit, deliberate wall to open.
apps/api/src/forms/forms.service.ts:3478-3482 rejects any signer who is not the assignee, and the comment at forms.controller.ts:562-566 says why: a bare token previously let anyone sign as the member, “including a minor consenting on their own behalf.” The team already thought about minors and chose to close the door.
New behavior does not relax that check; it replaces “token is valid” with “authority is proven”. form_instances.assigneeUserId is unchanged and points at the child’s own row; only the signer is separated from it.
- A managed member’s form carries
signerRequirement='guardian'. Self-signing by a minor is never permitted regardless of age — and note this is a policy choice, not a consequence of having no login. A teenage managed member may well hold a login; they still may not sign their own waiver. Forms that a minor may legitimately sign themselves (a code of conduct) carrysignerRequirement='self'or'either'. - A signature is accepted iff
signedInCapacity='self'and the caller is the assignee, orsignedInCapacity='guardian'and an active, unsuppressed guardianship withcanSignFormsexists at the moment of signing. form_signaturesrecordssignedByUserId,signerFullNametyped at signing,signerRelationship, and the verbatimattestationText. Today the table proves only that someone at an IP signed.- Guardianship is re-verified at submit, not at render. A revoked guardian with an open tab must not be able to sign.
- Youth parental consent.
forms.typeKey = 'parental_consent_youth'. When the org has a published, org-wide template of that type, it is issued to every managed member at creation and is a booking requirement for every managed member, whatever the template’s ownrequiredForBookingsays. The responsible adult signs it in the guardian capacity, in the setup wizard’s waiver step. An org without such a template gates nothing.
Re-signature triggers — a signed guardian consent becomes stale and must be re-collected when: guardianship changes hands; the form template version bumps; or the artefact’s validity lapses. For health artefacts that validity is statutory, not a product setting: a health declaration refreshes every 24 months, or 12 where a medical certificate was triggered, and a competing athlete needs a seasonal clearance. Expiry blocks booking. Reaching an age threshold triggers nothing automatically (§5).
Booking gate. bookings.service.book() calls getBookingFormRequirements(orgId, membership.userId, …) (bookings.service.ts:103-108). For a managed member membership.userId is the child’s own id, so the call resolves the child’s forms unchanged. Two things do change: the gate must honour expiresAt, not just status = 'signed', and the resulting 409 FORM_SIGNATURE_REQUIRED must deep-link the guardian to the form, in the child’s context.
4.4 Bookings and attendance
- Booking subject is the child’s
membershipId; actor isbookedByUserId. Capacity, duplicate prevention and quotas remain per member, so two siblings are two seats — which is correct, and is whatunique(classSessionId, membershipId)already gives us. - The whole booking pipeline — SERIALIZABLE transaction, quota checks, capacity minus aggregator-held seats,
deductCreditAtomic, outbox enqueue (bookings.service.ts:88-660) — is unchanged. Only subject resolution at the entry point changes: the caller may act for a member other than themselves. canBookis checked before entry.canBookcovers cancellation too: an adult who can create an obligation can undo it.- Sibling contention: two children, one full session, one guardian. The guardian sees one seat and must choose. Waitlist position is per member, so a household does not get a block of seats — deliberate, and it must be legible in the UI rather than looking like a bug.
- Co-parent conflict: guardian A books, guardian B cancels. Both have
canBook, so both actions are legitimate. The system’s job is not to arbitrate but to be unambiguous: every booking and cancellation shows who did it and when, to both guardians and to staff. This is whybookedByUserIdandcancelledByUserIdexist. - Late-cancellation fees charge the payer, not the child.
- Check-in: a guardian arriving with two children checks in both. The kiosk (
apps/web/src/app/[lang]/(protected)/kiosk/[sessionId]/) and self-check-in must support multi-member check-in in one pass, or the front desk queues. Phase 1: multi-member kiosk check-in is not built, and an adult cannot check a child in from their own phone (§4.7). Staff record a child’s attendance from the session as for any member.
4.5 Profile handling
- Identity is the managed member’s own
usersrow, read and written like any member’s. There is no second identity source to reconcile.canManageProfilegates a guardian’s edits, which cover contact details only: who the child is (name) and the address that identifies them (email — it blocks a duplicate teen sign-up and is what a later claim matches) stay with staff. canViewHealthgates body metrics, medical answers and injury notes — separable because a co-parent may legitimately need the schedule without the medical file.- Minisite exposure (R-07, corrected). Revision 1 called
memberships.showOnMinisitea live privacy defect. That was overstated. The column does default totrueon every membership row (libs/db/src/lib/schema/memberships.ts:42), but the minisite reads it only for coaches: the query filterseq(memberships.role, 'coach')and exposes only first and last name (apps/api/src/organizations/minisites.service.ts:699-708), and the visibility toggle only acceptsentity === 'coach'(:1137-1143). A member or a child is never exposed, and there is no leak today. The residual concern is latent: the flag istrueon every row and a single role filter is all that stands between it and a public page, so a future “our members” section would publish everyone by default. Managed members default tofalseanyway, as defence in depth, and changing it requires guardian consent (TKN-405 (WP-10)). - Progress photos are hard-blocked for managed members.
progress_photoskeys onmembershipIdand holds body photographs; photographs of children are the single highest-severity surface in this feature (R-08). There is no org toggle, no consent path and no staff override: every progress-photo endpoint (apps/api/src/progress-photos/progress-photos.controller.ts) returns 403 when the membership belongs to a managed member, whoever the caller is. Delivered in WP-10. - Media/likeness consent is a separate, revocable, per-child consent — not bundled into the waiver. Required before any community post or minisite appearance.
4.6 Messaging and notifications
No messaging for managed members in phase 0 (Q-05, decided). No thread is anchored to a managed member, conversations is unchanged, and conversations.subjectUserId moves to a later phase (data-model.md §3.8). canMessageStaff is stored on the grant so the capability set is stable, but gates nothing yet. A guardian can still message staff about anything as themselves, through the channels that exist today.
Phase 1: on a child’s record, staff get Message {guardian} about {child}, which opens a staff conversation with the primary adult (/dashboard/messages?to=<adult's membershipId>, apps/web/src/components/overview/members/member-detail-page.tsx:444), plus a WhatsApp link to the adult. The thread belongs to the adult; nothing is anchored to the child.
Notification routing stays, because it is fan-out, not messaging. Delivered in TKN-404 (WP-09).
deviceTokensandnotification_prefskey onusers.id(libs/db/src/lib/schema/device-tokens.ts:27,libs/db/src/lib/schema/notification-prefs.ts:28). A managed member has ausers.id, sopush.notifyUser(membership.userId, …)atbookings.service.ts:646succeeds and reaches nobody whenever that member has no device registered. That failure is silent under any compiler setting — the id is non-null, so no configuration ofstrictNullCheckswill flag it (risks.mdR-19, R-22). Every notification whose subject is a managed member must fan out to guardians holding an active, unsuppressed grant withcanReceiveNotifications.- An email to a managed member may have no address. Where it does not, that is a legitimate terminal state for the send: skip and record, never throw, and never substitute an empty string (
data-model.md§6). Where the member does hold an address, routing still follows the guardianship — holding an email does not make someone the right recipient for a billing failure on a plan their parent pays for. - Per-child muting is not in phase 0.
notification_prefsis unchanged and unique per user (notification-prefs.ts:39), so a guardian’s preferences apply to all their children alike.
Message purpose. Every outbound message carries messagePurpose ∈ {transactional, promotional}. Israeli marketing rules forbid any marketing method from using a minor’s personal details — name, email, phone — without a parent’s consent, and minor means under 18. Fan-out in a child’s name is therefore transactional only; promotional sends touching a minor are hard-blocked. The same rules rule out streak pressure, scarcity mechanics and “ask your parent” nudges in any child-facing surface.
What a later phase must handle before any messaging reaches a managed member. In phase 0 no thread is anchored to a managed member at all, which is a product exclusion rather than a structural guarantee — the earlier framing (“structurally impossible, a managed member has no login”) was wrong, since a managed member may hold a login. It becomes a live question the moment threads about a child exist, and more so once a teen holds their own login: a private coach↔minor channel is a safeguarding risk in most jurisdictions, and guardian-visible-by-default is the safer setting. But guardian visibility is exactly what a child disclosing abuse at home needs not to happen, which is why this needs counsel rather than a default.
Announcements are org-wide with no audience targeting (libs/db/src/lib/schema/announcements.ts). A “parents of children in Beginner Swim” announcement is not expressible. Out of scope; recorded as a gap.
4.7 Viewing as a child
Chosen: an app-level “view as” context, on web and mobile alike (product decision 2026-10-09). A responsible adult picks a child in the header switcher and the member app re-scopes to that child: Home, Program, Schedule and the child’s half of Profile. It is delegation, never impersonation — the adult stays themselves, every write is attributed to them (bookedByUserId, cancelledByUserId, actorCapacity: 'guardian' in audit), and the switcher always names who is being viewed.
- Who sees the switcher. Any adult with at least one confirmed link (
listAccessibleMembers: active, unsuppressed, unexpired, valid in this org, child’s membership active). Pending links grant nothing and do not show; they wait under Profile → Family. The header switcher is absent on adult-only routes (messages, library, check-in, the adult’s own payments and personal details) and on the Shop, which has its own “Who is this for?” choice. - Where the app starts. With no choice stored,
defaultActingFor(libs/shared/src/lib/acting-for.ts) decides from the household’sself(participation,hasOwnPlan):- a parent who does not train (
participation = 'guardian_only') opens on their first child and cannot view themselves, because their own schedule would offer bookings the server refuses. Their own row stays in the switcher, captioned “Get a plan for yourself”, and opens the Shop on them (/shop?for=me); the session’s guardian-only notice links there too. The household’sself.participationdecides this once loaded, ahead of the longer-cached/users/me: the adult’s own checkout refetches the household, so the self row appears on its return; a plan sold at the desk shows on the next household refresh; - a parent with no plan of their own and exactly one child opens on that child — the parent who came to enrol their kid;
- anyone else opens on themselves. An explicit “myself” is remembered as a choice, so it is not overridden by that default. A stored child whose grant is gone counts as no choice, so the default applies again (for a parent with no plan, that can be a remaining only child). The selection is stored per organisation and cleared on sign-out or a change of signed-in identity.
- a parent who does not train (
- Who a purchase or booking is for is named on the action, not left to the header (
actingOptions,resolveActingFor):- Shop: a “Who is this for?” choice (“Me” plus each child the adult holds
canManageSubscriptionover), starting on whoever the app acts for (a guardian-only adult whose current child cannot be bought for starts on their first child who can). For a child:- the heading reads “Plans for {name}”;
- current/pending/switch state and pack credits come from the child’s own plans (
members/:id/my-subscriptions), and no plan action is offered until those have loaded; - the purchase sends
onBehalfOfUserId(the adult is the payer); - course plans are not offered, and the per-plan “requires signing” hint is not shown (it is computed for the adult);
- unsigned plan forms are signed inline in the guardian capacity under the adult’s profile name, or in the child’s setup step when that is not possible;
- the return page confirms “{name}‘s plan is active”.
A guardian-only adult can still choose “Me”: a plan of their own is how they start training, and they become
trainswhen that plan goes live (paid, free, or a completed presale), not when checkout opens. The choice cannot change while a checkout is starting.
- Booking: a “Booking for” choice on the session when more than one person can be booked (the adult only if they train, plus each child with
canBook); choosing re-scopes the app, and no booking action is offered until the session has reloaded with that person’s eligibility. With one possible person there is no choice, and the button names them (“Book for {name}”). A child with no plan gets “Get {name} a plan”, which opens the Shop already set to that child — only when the adult may buy for that child (canManageSubscription); otherwise the notice stands alone.
- Shop: a “Who is this for?” choice (“Me” plus each child the adult holds
- Opening the accept link in the app.
/family/accept/*and its locale-prefixed forms are App Links on iOS (apple-app-site-association, excluding links that carry a Clerk sign-up ticket, which only the web page can complete) and Android (taikan-mobile/app.config.ts). - Reads. Every child read resolves through
GuardianAccessService.actingMembership(orgId, actor, forUserId, capability): the feature flag first, then the capability for that read (canViewSchedule,canViewAttendance,canViewProgress,canViewHealth), suppression honoured; a read of oneself skips both. A section the grant does not cover shows a “not shared” state and makes no request. - Training data is view-only. No logging, results, PRs, goals, body metrics, comments, workout chat or check-in for a child from the adult’s phone — including by deep link. The writes that exist are the capability-gated ones: book and cancel (
canBook), sign forms in guardian capacity (canSignForms), plan and payment (canManageSubscription,canPay), and contact details (canManageProfile, §4.5). - Profile splits in two. The child’s part follows the switcher (details, plan, forms, history, goals, PRs, metrics — never progress photos). “Your account” is always the adult’s: Family, payments and cards, notifications, language, legal, sign-out, delete account. There is no Family tab; the Family area lives under Profile and refetches the household when opened.
- Isolation. Child-scoped responses are keyed by the child and never stand in for another member’s data across a switch; on mobile they are excluded from the persisted offline cache.
- Push. A notification about a child carries
subjectUserId; tapping it switches to that child before opening the screen.
5. Age tiering
Chosen: managed members, with no automatic age-triggered access changes. Review removed the majority cron, the automatic capability downgrade on claim and the automatic grant expiry at 18. Age changes a family’s arrangements; the software records who holds authority, it does not rewrite that authority on a birthday.
5.1 Thresholds
| Setting | Default | Rationale |
|---|---|---|
selfAccessMinAge | off | Org setting, off unless the org turns it on (Q-04). A product threshold, not a legal one. Israel has no age of digital consent: קטין is anyone under 18, בגיר is 18+, and there is no intermediate legal tier (חוק הכשרות ס’3). The Privacy Protection Law contains no age threshold at all and the word קטין does not appear in it. So there is no Israeli 13 or 16 to align to, and inventing one would be wrong. An org that turns it on should start at 16: it is when the physical ת.ז. is issued, it matches Swim England’s self-release threshold, and it is the GDPR Art. 8 fallback — which makes the same value safe in the EU, where the number is legal and varies 13–16 by member state. Amilia gives a 14+ member their own login and wallet, so 14 is also defensible. |
majorityAge | 18 | Israeli legal majority (חוק הכשרות ס’3). Configurable per country, never per org. Defines who is a minor for the marketing rule (§4.6) and consent (§5.1a). Triggers nothing automatically. |
managedMinAge | 0 | Replaces the hard MIN_AGE_YEARS = 13 floor for managed members only. Note a statutory floor exists elsewhere: minors under 6 may not train in a gym at all (חוק מכוני כושר ס’3), which is a scheduling constraint rather than an identity one. |
| Minimum guardian age | 21 | On the adult, not the child — see §2 invariant 7. |
Phase 1: none of these is an org setting. selfAccessMinAge gates only the claim flow, which is not built; managedMinAge is the constant MANAGED_MIN_AGE_YEARS = 0 (libs/shared/src/lib/schemas/guardianship.schema.ts:30); the minimum guardian age is enforced by GuardianAccessService.assertGuardianAge (apps/api/src/guardianships/guardian-access.service.ts:312).
The self-serve birth-date check (libs/shared/src/lib/validation/person.ts:56, MIN_AGE_YEARS = 13) is unchanged: a 9-year-old must still not be able to self-register. Managed members validate against their own floor, MANAGED_MIN_AGE_YEARS. Loosening the shared self-serve constant would silently open self-registration to children. Keeping the two separate is mandatory, not stylistic.
5.1a The consent quorum, and why it cannot be a constant
Israeli and French law disagree, and the disagreement is load-bearing.
- Israel: both parents must act by agreement, but one parent’s consent to the other’s act may be prior or retroactive, express or implied, and there is a rebuttable presumption that the other parent consented until the contrary is proven (חוק הכשרות ס’18). For gym training specifically, the written consent of one parent suffices by statute (חוק מכוני כושר ס’4(ג)).
- France: parental authorisation must in principle come from both parents, irrespective of relationship status or whether they live together (CNIL). Under 15, consent is given by the parent jointly with the child.
So consentQuorum is a per-jurisdiction policy: single_guardian for Israel, all_active_guardians for France. Any single-guardian shortcut hard-coded for the Israeli case is unlawful in France, and that is a schema-shaped problem, not a copy problem.
The objection flag. Because the Israeli presumption is rebuttable, a guardian must be able to rebut it. guardianships.objectionLodged escalates that one managed member to all_active_guardians regardless of the org policy. This is the co-parenting conflict case expressed as data rather than as a support ticket, and it is the mechanism that lets the platform stop taking sides.
Consent scope and timing are first-class. חוק הכשרות ס’4 permits consent for a single act or a class of acts, given in advance or ratified afterwards, and ס’4(ג) of the gym law scopes parental training consent to a session or a class of sessions. So “authorise all bookings for this program this term” is expressly lawful in Israel and must be representable — consentScope + consentTiming, not a boolean.
5.2 Claiming a login
The claim flow is not in phase 0 or phase 1 and no work package builds it. The policy is fixed now so that it is not re-litigated when it is built:
- Claiming is guardian-initiated, and a guardian’s veto stands. A 16-year-old cannot unilaterally seize their record, because that is a family conversation the software should not force.
- It happens only in an org that has turned
selfAccessMinAgeon, and only once the member has reached it. - The member keeps their own
usersrow: it gains an email and a Clerk identity andkindbecomes'account'. No history moves, because it was always theirs. - Guardianships are not downgraded or expired by the claim. Any change to a remaining grant is an explicit act, made subject to the consent quorum.
5.2a A teen signing up with an address their child record holds
Until the claim flow exists, a sign-up whose email a managed member already holds is refused, not turned into a second, unlinked account: /users/me answers 403 signup_email_held_by_managed, and the apps show “ask your gym to link your account”. The Clerk webhook acknowledges the event with 2xx (a retry cannot change the answer) and writes an audit row with ids only — the address is the child’s data. A child’s email is staff’s to set (§4.5), so a parent cannot use it to block or redirect a sign-up.
5.3 Majority
Nothing happens automatically at 18. Grants stay as they are until someone revokes them. This deliberately avoids severing a payer mid-term, which breaks billing, and avoids silently removing access a family still relies on.
The cost, stated: a grant can outlive the legal authority behind it. A guardian’s standing to sign for an adult ends at majority in law even though the grant row does not change. Ending the arrangement is an explicit act by the guardian or by staff, not a cron.
5.4 What is deliberately not built
Per-age-band privacy defaults — a 16-year-old automatically controlling their own body metrics while the guardian retains attendance visibility. The capability mechanism supports it; the policy configuration does not ship. This is a real gap for EU deployment, where a 16-year-old is their own data controller while a parent still pays, and the correct default differs by member state. It is a stated gap, not a solved problem.
6. Organization settings
The whole feature is gated to the Pro tier (Q-14), per the platform model in PRODUCT.md.
Phase 1 builds no settings surface (WP-16). Only the first row exists, as the flag described in §6a. The other rows are the intended settings; in phase 1 their defaults are fixed in code where noted as hard defaults (promotional messages, minisite, progress photos), the consent quorum is single_guardian unless an adult has lodged an objection (GuardianAccessService.consentQuorum, guardian-access.service.ts:302), and second guardians are API-only (§3.2).
| Setting | Default | Notes |
|---|---|---|
| Managed members enabled | off | Pro tier only. Behind PostHog flag managed-members, per the WebhookEnforcementService pattern (apps/api/src/payments/services/webhook-enforcement.service.ts:30,51-55) — distinctId = orgId, groups: { organization: orgId }, only === true proceeds. |
selfAccessMinAge | off | See §5.1. Enables nothing in phase 0: the claim flow it gates is out of phase 0 (§5.2). The default is fixed now so it cannot drift on. |
managedMinAge | 0 | |
| Second-guardian policy | primary-invite only | or: allow staff-approved requests |
consentQuorum | single_guardian (IL) | Per-country default; all_active_guardians for FR. Escalated per managed member by objectionLodged. |
| Health declaration validity | 24 months | Statutory (חוק מכוני כושר ס’4(ד)). 12 months where a medical certificate was triggered. |
| Seasonal clearance required | off | For competing athletes (תקנות הספורט (בדיקות רפואיות)). |
| Promotional messages to minors | blocked | Hard default. Israeli marketing rules bind until 18; see §4.6. |
| Photo/media consent mode | guardian + member | Dual-key per pattern P14; either veto stands, and refusal never blocks registration. Governs media and likeness only — progress photos are blocked outright (§4.5). |
| Guardian consent validity | 12 months | Drives re-signature for non-health consents. |
| Managed members on minisite | off | Hard default; see §4.5 and R-07. |
| Required forms for managed members | — | Which templates gate booking |
There is deliberately no progress-photos setting. The block in §4.5 is not configurable.
6a. Feature flag and kill switch
ManagedMembersFlagService.enabled(orgId) (apps/api/src/guardianships/managed-members-flag.service.ts) is true only when the org’s tier includes managed_members and the PostHog flag managed-members (FeatureFlags.MANAGED_MEMBERS) is true for the org (distinctId = orgId, group organization). An unset flag or an evaluation error is off; a PostHog outage serves the last known value.
Gated (off: 403 managed_members_disabled, or hidden):
| Where | What |
|---|---|
GuardianAccessService.actingMembership (guardian-access.service.ts:124) and GuardianCapabilityGuard (guardian-capability.guard.ts:82) | Every read or write on someone else’s behalf: schedule and booking (class-sessions.service.ts), programs, workouts, results, goals, body metrics, member detail. Self reads skip the flag. |
guardianships.service.ts (household, pending detail, invite, accept, decline, request, approve, reject, capabilities, revoke, make-primary, objection, suppress, unsuppress) | All guardianship routes. |
managed-members.service.ts:119,326,436,473 | Staff Add child, child profile edits, resend, the import entry. |
memberships.service.ts:663,1511 | The Responsible adults filter and the guardian’s member view. |
forms.service.ts:280,4466,3666 | Guardian signing, a guardian reading a child’s forms, and the youth-consent booking requirement. |
check-ins.service.ts:145, body-metrics.service.ts:419, plans.service.ts:1082,1846, plan-change.service.ts:183, subscriptions.service.ts:5043 | Check-in questionnaires, metrics, buying and changing a child’s plan. |
import.service.ts:987 | CSV rows become managed members only with the flag on. |
Web staff UI (members-content.tsx:234, member-detail-page.tsx:102, member-household-section.tsx:47) | Add child, the filter, the household section: tier and flag. |
| Web and mobile member apps | A refused household request hides the switcher and the Family area. |
Deliberately ungated, because once a child exists these must keep applying whatever the flag says: the schema and nullable-email handling; notification routing to a child’s adults (notification-recipients.service.ts); payer, receipt and card-owner routing (payer-resolver.service.ts, payment-method.service.ts); bookedByUserId / cancelledByUserId; the safety guards (promotional sends, automations, analytics, insights and lead matching, minisite, progress photos); the teen sign-up block (users.service.ts:139,251); promoteToTrainsOnOwnPlan (apps/api/src/memberships/participation.ts:15).
Turning it off for an org takes effect immediately. Children stay ordinary members (roster, bookings, plans, billing), their notifications and receipts still reach their adults, and links are kept; parents lose Family, the switcher and acting for a child; a child books without the youth-consent requirement. Turning it back on restores everything. The migration has no down step; see rollout-runbook.md.
7. Edge cases
The ticket asks explicitly to validate edge cases. These are the ones with defined answers; the ones needing a decision are in risks.md.
7.1 Identity and structure
- Guardian also trains. Normal: one
usersrow with their ownparticipation='trains'membership, plus guardianships over their children. Nothing about their own record changes. - Guardian is staff. A coach whose child trains at their own gym. Their staff role and their guardianship are orthogonal; staff scope must not silently widen guardian access, and their child’s record must not be more exposed because a parent works there.
- Two guardians, both primary-eligible. DB rejects the second. UI must offer “make primary instead”, which demotes the incumbent in one transaction.
- Child at two branches. One
usersrow, two memberships, one global guardianship. Guardian switching orgs sees the same child with branch-specific enrollment. - Sibling who is also a guardian. A 22-year-old sibling guardian of a 10-year-old is allowed via
relationship='sibling': they hold a login and the age check is on the guardian, not the relationship. A 19-year-old sibling is below the minimum guardian age of 21 and cannot hold a grant; only an 18-to-20-year-old parent has the staff-approved override (§2 invariant 7). - Guardian deletes their Clerk account.
user.deletedsoft-deletes theusersrow (apps/api/src/webhooks/clerk-webhook.controller.ts:236-254). If they were the sole primary, the child is orphaned — see 7.4. The webhook must detect this and raise, not proceed silently (R-15).
7.2 Booking and entitlement
- Guardian revoked mid-session. Attendance already recorded stays with the child. Future bookings by that guardian are blocked immediately.
- Two siblings, one remaining seat. Guardian picks. No household seat reservation.
- Child ages out of a class mid-term. Age eligibility is TKN-383, but
birthDatebecomes load-bearing here and an existing recurring enrollment must not be silently terminated by a birthday. - Guardian books, other guardian cancels, credit returns. Credit returns to the child’s entitlement, never to the canceller.
7.3 Billing
- Payer is not a guardian. Grandparent pays, parent books. Supported:
payerUserIdis independent of any guardianship. The payer still needs a membership row (participation='guardian_only') to be billed and receipted. - Payer revoked as guardian but still liable. Payer status survives guardianship revocation until the subscription ends or the payer is replaced. Losing access to a child’s record does not cancel a debt.
- Card fails. Child blocked from booking; guardian notified; child’s screen explains without blaming the child.
7.4 Orphaned managed members
- No active primary guardian — sole guardian deleted, revoked or deceased, or an import row whose guardian could not be resolved. This is an incident state, not a valid one. Behavior: the managed member is frozen (no new bookings), existing bookings stand, staff get a high-priority queue item, and staff can assign a new primary with
source='staff_approved'. It must never fail silently, and it must never cascade-delete a child’s history.
7.5 Integrations
- Vendor mirror.
bookings.service.book()enqueues outbox rows mirroring the booking to Fizikal/Arbox (bookings.service.ts, post-commit). A managed member may have no email, and those vendors may require one. This will fail for exactly the customer whose children motivated the feature. Must be tested against a real vendor sandbox before the pilot, not assumed. R-04; TKN-414 (WP-19).
8. Acceptance criteria
From the ticket, made testable.
| # | Criterion | Test |
|---|---|---|
| A1 | Staff can enrol a child with no email or login, and the named adult can confirm it. | Integration: staff create a managed member → users row with kind='managed', email IS NULL, clerkId IS NULL; a membership keyed on that userId with participation='trains'; a pending primary guardianship (source='staff_enrolled') that becomes active when the adult accepts (staff-enrolment.int.spec.ts). Plus the orthogonality case: a managed member holding an email still resolves as managed, and its guardian still holds authority. |
| A2 | A guardian can book for a child. | Integration: canBook guardian books → booking row with membershipId = child, bookedByUserId = guardian. |
| A3 | Attendance and entitlement attach to the child. | Integration: after check-in, attendance queries by child’s membership return it; guardian’s own attendance is unchanged; credit decremented on the child’s subscription. |
| A4 | An unrelated adult cannot access that record. | Integration: adult with no guardianship gets 403 on every member-scoped endpoint for the child — profile, bookings, forms, health, subscription. Must include the currently-weak GET /members/:membershipId (memberships.service.ts:1181-1205), which today returns full PII to any org member and is part of this ticket’s security scope (TKN-399 (WP-04)). |
| A5 | Multiple children per guardian. | Integration: three children, independent subscriptions, bookings and quota allowances, no cross-contamination. |
| A6 | Multiple guardians per child with different capabilities. | Integration: secondary with canBook but not canManageSubscription books successfully and is refused on subscription mutation. |
| A7 | Explicit booking and viewing permissions enforced. | Integration: matrix test over all thirteen capabilities × endpoints. Table-driven. |
| A8 | Staff actions work and are attributed. | Integration: staff books for a child → actorCapacity='staff', audit row with subjectUserId = the child. |
| A9 | Existing adult memberships are unaffected. | Planned: adult-migration-parity.int.spec.ts snapshotting every member-facing response before and after the migration. Not written in phase 1. Covered instead by the strict-null type-check gate and every existing API and web suite passing on the migrated schema (data-model.md §5). The migration touches existing rows only by setting empty-string emails to NULL. |
| A10 | Revocation is immediate. | Integration: revoke, then every previously-allowed call returns 403 with no cache window. |