exposeAgePublicly parental permission + Age on SponsorFamilyMember (consent-boundary design) #873

Closed
opened 2026-07-27 14:30:06 +00:00 by spikerj · 1 comment
Owner

Part of #868 (PR-5 backend / PR-6 angular pair). Consent-boundary change — design recorded here per COPPA doctrine before code.

Design (consent boundary)

What is exposed: an integer age (10–17 by construction of the child-account COPPA bracket) on the anonymous GET /api/sponsor/families payload, as SponsorFamilyMember.Age: int?.

Who consents: the linked parent, and only the parent. The flag lives in ParentalControls.Permissions (FeaturePermissions.ExposeAgePublicly, default false) — the parent-set entity. SponsorshipSettings is deliberately NOT used: children cannot write it (ApplySponsorshipAsync no-ops for under-18s), so it cannot express parental consent. Precedent: PhotoLocationSharing (parent grants a public exposure capability).

Gate location: resolved server-side in SponsorFamilyBuilder.BuildMember, identical shape to ApprovedDestinationCodes:
Age = isChild && p.ParentalControls?.Permissions?.ExposeAgePublicly == true && p.DateOfBirth.HasValue ? CalculateAge(p.DateOfBirth.Value) : null
Adults never get an age (they show "Parent"). The DTO carries the derived int only — DateOfBirth never leaves the server, and age/DOB never appear in logs or telemetry.

Withdrawal: parent toggles off → next listing rebuild emits null; nothing is persisted client-side.

Not in scope: solo child cards (SponsorProfileListItem has no parental data path) — separate ticket if ever wanted.

Implementation checklist (lockstep chain)

  1. FeaturePermissions.ExposeAgePublicly ([JsonProperty("exposeAgePublicly")]) in ProfileParentalSubDocuments.cs
  2. FeaturePermissionsDto (ProfileDtos.cs)
  3. ProfileMapper.ToDto (the #736 lesson)
  4. GetChildrenQueryHandler read mapper
  5. UpdateChildPermissionsCommandHandler write mapper
  6. SetChildBookApprovalCommandHandler full-copy block (else book grants clobber the flag)
  7. ToDto_ShouldMapAllThirteenFeaturePermissionFields → Fourteen
  8. Handle_ShouldRoundTrip_AllPermissionFields_WithoutClobber
  9. ApproveParentalLinkCommandHandler explicit ExposeAgePublicly = false (NOTE: this handler already silently resets InfoVault to false — latent bug, separate ticket)
  10. Extract shared CalculateAge into SpikerSoft.Common; point the 4 duplicated private copies at it (ProfileDtos, UpdateProfileCommandHandler, GetProfileQueryHandler, ConvertChildToIndividualCommandHandler)
  11. SponsorFamilyMember.Age: int? + BuildMember gate
  12. ChildProfileSummaryDto.Age: int? + GetChildren mapper (so the parent toggle can show what it exposes)

Angular half (PR-6): FeaturePermissions TS + ChildProfileSummary.age?; parent-dashboard mat-slide-toggle nested under sponsorship-enabled (pattern: photoLocationSharing under photography); card tag shows Age {n} when present else Child/Parent; i18n en+es. QA: e2e parent walk + manual QA per COPPA flow rule.

Part of #868 (PR-5 backend / PR-6 angular pair). **Consent-boundary change — design recorded here per COPPA doctrine before code.** ## Design (consent boundary) **What is exposed:** an integer age (10–17 by construction of the child-account COPPA bracket) on the anonymous `GET /api/sponsor/families` payload, as `SponsorFamilyMember.Age: int?`. **Who consents:** the linked parent, and only the parent. The flag lives in `ParentalControls.Permissions` (`FeaturePermissions.ExposeAgePublicly`, default **false**) — the parent-set entity. `SponsorshipSettings` is deliberately NOT used: children cannot write it (`ApplySponsorshipAsync` no-ops for under-18s), so it cannot express parental consent. Precedent: `PhotoLocationSharing` (parent grants a public exposure capability). **Gate location:** resolved server-side in `SponsorFamilyBuilder.BuildMember`, identical shape to `ApprovedDestinationCodes`: `Age = isChild && p.ParentalControls?.Permissions?.ExposeAgePublicly == true && p.DateOfBirth.HasValue ? CalculateAge(p.DateOfBirth.Value) : null` Adults never get an age (they show "Parent"). The DTO carries the derived int only — **DateOfBirth never leaves the server**, and age/DOB never appear in logs or telemetry. **Withdrawal:** parent toggles off → next listing rebuild emits null; nothing is persisted client-side. **Not in scope:** solo child cards (`SponsorProfileListItem` has no parental data path) — separate ticket if ever wanted. ## Implementation checklist (lockstep chain) 1. `FeaturePermissions.ExposeAgePublicly` (`[JsonProperty("exposeAgePublicly")]`) in `ProfileParentalSubDocuments.cs` 2. `FeaturePermissionsDto` (ProfileDtos.cs) 3. `ProfileMapper.ToDto` (the #736 lesson) 4. `GetChildrenQueryHandler` read mapper 5. `UpdateChildPermissionsCommandHandler` write mapper 6. `SetChildBookApprovalCommandHandler` full-copy block (else book grants clobber the flag) 7. `ToDto_ShouldMapAllThirteenFeaturePermissionFields` → Fourteen 8. `Handle_ShouldRoundTrip_AllPermissionFields_WithoutClobber` 9. `ApproveParentalLinkCommandHandler` explicit `ExposeAgePublicly = false` (NOTE: this handler already silently resets `InfoVault` to false — latent bug, separate ticket) 10. Extract shared `CalculateAge` into `SpikerSoft.Common`; point the 4 duplicated private copies at it (ProfileDtos, UpdateProfileCommandHandler, GetProfileQueryHandler, ConvertChildToIndividualCommandHandler) 11. `SponsorFamilyMember.Age: int?` + BuildMember gate 12. `ChildProfileSummaryDto.Age: int?` + GetChildren mapper (so the parent toggle can show what it exposes) **Angular half (PR-6):** `FeaturePermissions` TS + `ChildProfileSummary.age?`; parent-dashboard `mat-slide-toggle` nested under sponsorship-enabled (pattern: `photoLocationSharing` under `photography`); card tag shows `Age {n}` when present else Child/Parent; i18n en+es. QA: e2e parent walk + manual QA per COPPA flow rule.
Author
Owner

Resolved in spikersoft-backend PR #497 + spikersoft-angular PR #592 (consolidated epic PRs), with the lint follow-up in angular PR #593. The standalone PRs #495/#590 were closed unmerged in favour of the consolidation. Verified against origin/masterall 12 backend checklist items present:

  1. ProfileParentalSubDocuments.cs:88-89 ([JsonProperty("exposeAgePublicly")])
  2. ProfileDtos.cs:204FeaturePermissionsDto
  3. ProfileDtos.cs:408ProfileMapper.ToDto
  4. GetChildrenQueryHandler.cs:47
  5. UpdateChildPermissionsCommandHandler.cs:51
  6. SetChildBookApprovalCommandHandler.cs:80 full-copy block + regression test SetChildBookApprovalCommandHandlerTests.cs:112 — this is the #736-class clobber guard
  7. ProfileMapperTests.cs:176 renamed to ToDto_ShouldMapAllFourteenFeaturePermissionFields
  8. UpdateChildPermissionsCommandHandlerTests.cs:113 + assertion :151
  9. ApproveParentalLinkCommandHandler.cs:45 — explicit ExposeAgePublicly = false
  10. SpikerSoft.Common/Extensions/AgeCalculator.cs:11 — all four former private copies now delegate (ProfileDtos.cs:271,441, UpdateProfileCommandHandler.cs:115,269, GetProfileQueryHandler.cs:69, ConvertChildToIndividualCommandHandler.cs:32); confirmed no fifth copy survives
  11. SponsorDtos.csSponsorFamilyMember.Age: int? with the consent gate at SponsorFamilyBuilder.cs:373-374
  12. ProfileDtos.cs:256ChildProfileSummaryDto.Age + mapper at GetChildrenQueryHandler.cs:31

Angular half: user-profile.model.ts:179,197; sponsor.service.ts:83; the toggle correctly nested under @if (child.sponsorshipEnabled) at parent-dashboard.component.html:213-232 (data-testid="expose-age-toggle"); card tag at family-card.component.ts:97-98; i18n in en + es.

Two notes, neither blocking:

  • The generated TEST-QUALITY-AUDIT.md:3732 still says "Thirteen" — stale generated doc, not a code gap.
  • Item 9's ExposeAgePublicly = false line satisfies this ticket only. It does not fix #876 (the same handler still clobbers InfoVault); that stays open.

The post-deploy COPPA walk (parent toggle on/off against anonymous /sponsor) is tracked on the epic and remains unrecorded — the automated non-clobber regression exists, the manual walk doesn't.

Closing.

Resolved in spikersoft-backend PR #497 + spikersoft-angular PR #592 (consolidated epic PRs), with the lint follow-up in angular PR #593. The standalone PRs #495/#590 were closed unmerged in favour of the consolidation. Verified against `origin/master` — **all 12 backend checklist items present**: 1. `ProfileParentalSubDocuments.cs:88-89` (`[JsonProperty("exposeAgePublicly")]`) 2. `ProfileDtos.cs:204` — `FeaturePermissionsDto` 3. `ProfileDtos.cs:408` — `ProfileMapper.ToDto` 4. `GetChildrenQueryHandler.cs:47` 5. `UpdateChildPermissionsCommandHandler.cs:51` 6. `SetChildBookApprovalCommandHandler.cs:80` full-copy block + regression test `SetChildBookApprovalCommandHandlerTests.cs:112` — this is the #736-class clobber guard 7. `ProfileMapperTests.cs:176` renamed to `ToDto_ShouldMapAllFourteenFeaturePermissionFields` 8. `UpdateChildPermissionsCommandHandlerTests.cs:113` + assertion `:151` 9. `ApproveParentalLinkCommandHandler.cs:45` — explicit `ExposeAgePublicly = false` 10. `SpikerSoft.Common/Extensions/AgeCalculator.cs:11` — all four former private copies now delegate (`ProfileDtos.cs:271,441`, `UpdateProfileCommandHandler.cs:115,269`, `GetProfileQueryHandler.cs:69`, `ConvertChildToIndividualCommandHandler.cs:32`); confirmed no fifth copy survives 11. `SponsorDtos.cs` — `SponsorFamilyMember.Age: int?` with the consent gate at `SponsorFamilyBuilder.cs:373-374` 12. `ProfileDtos.cs:256` — `ChildProfileSummaryDto.Age` + mapper at `GetChildrenQueryHandler.cs:31` Angular half: `user-profile.model.ts:179,197`; `sponsor.service.ts:83`; the toggle correctly nested under `@if (child.sponsorshipEnabled)` at `parent-dashboard.component.html:213-232` (`data-testid="expose-age-toggle"`); card tag at `family-card.component.ts:97-98`; i18n in en + es. Two notes, neither blocking: - The generated `TEST-QUALITY-AUDIT.md:3732` still says "Thirteen" — stale generated doc, not a code gap. - Item 9's `ExposeAgePublicly = false` line satisfies *this* ticket only. It does **not** fix #876 (the same handler still clobbers `InfoVault`); that stays open. The post-deploy COPPA walk (parent toggle on/off against anonymous `/sponsor`) is tracked on the epic and remains unrecorded — the automated non-clobber regression exists, the manual walk doesn't. Closing.
Sign in to join this conversation.