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)
FeaturePermissions.ExposeAgePublicly ([JsonProperty("exposeAgePublicly")]) in ProfileParentalSubDocuments.cs
ApproveParentalLinkCommandHandler explicit ExposeAgePublicly = false (NOTE: this handler already silently resets InfoVault to false — latent bug, separate ticket)
Extract shared CalculateAge into SpikerSoft.Common; point the 4 duplicated private copies at it (ProfileDtos, UpdateProfileCommandHandler, GetProfileQueryHandler, ConvertChildToIndividualCommandHandler)
SponsorFamilyMember.Age: int? + BuildMember gate
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.
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:
SetChildBookApprovalCommandHandler.cs:80 full-copy block + regression test SetChildBookApprovalCommandHandlerTests.cs:112 — this is the #736-class clobber guard
ProfileMapperTests.cs:176 renamed to ToDto_ShouldMapAllFourteenFeaturePermissionFields
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
SponsorDtos.cs — SponsorFamilyMember.Age: int? with the consent gate at SponsorFamilyBuilder.cs:373-374
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 notfix#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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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/familiespayload, asSponsorFamilyMember.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.SponsorshipSettingsis deliberately NOT used: children cannot write it (ApplySponsorshipAsyncno-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 toApprovedDestinationCodes:Age = isChild && p.ParentalControls?.Permissions?.ExposeAgePublicly == true && p.DateOfBirth.HasValue ? CalculateAge(p.DateOfBirth.Value) : nullAdults 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 (
SponsorProfileListItemhas no parental data path) — separate ticket if ever wanted.Implementation checklist (lockstep chain)
FeaturePermissions.ExposeAgePublicly([JsonProperty("exposeAgePublicly")]) inProfileParentalSubDocuments.csFeaturePermissionsDto(ProfileDtos.cs)ProfileMapper.ToDto(the #736 lesson)GetChildrenQueryHandlerread mapperUpdateChildPermissionsCommandHandlerwrite mapperSetChildBookApprovalCommandHandlerfull-copy block (else book grants clobber the flag)ToDto_ShouldMapAllThirteenFeaturePermissionFields→ FourteenHandle_ShouldRoundTrip_AllPermissionFields_WithoutClobberApproveParentalLinkCommandHandlerexplicitExposeAgePublicly = false(NOTE: this handler already silently resetsInfoVaultto false — latent bug, separate ticket)CalculateAgeintoSpikerSoft.Common; point the 4 duplicated private copies at it (ProfileDtos, UpdateProfileCommandHandler, GetProfileQueryHandler, ConvertChildToIndividualCommandHandler)SponsorFamilyMember.Age: int?+ BuildMember gateChildProfileSummaryDto.Age: int?+ GetChildren mapper (so the parent toggle can show what it exposes)Angular half (PR-6):
FeaturePermissionsTS +ChildProfileSummary.age?; parent-dashboardmat-slide-togglenested under sponsorship-enabled (pattern:photoLocationSharingunderphotography); card tag showsAge {n}when present else Child/Parent; i18n en+es. QA: e2e parent walk + manual QA per COPPA flow rule.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:ProfileParentalSubDocuments.cs:88-89([JsonProperty("exposeAgePublicly")])ProfileDtos.cs:204—FeaturePermissionsDtoProfileDtos.cs:408—ProfileMapper.ToDtoGetChildrenQueryHandler.cs:47UpdateChildPermissionsCommandHandler.cs:51SetChildBookApprovalCommandHandler.cs:80full-copy block + regression testSetChildBookApprovalCommandHandlerTests.cs:112— this is the #736-class clobber guardProfileMapperTests.cs:176renamed toToDto_ShouldMapAllFourteenFeaturePermissionFieldsUpdateChildPermissionsCommandHandlerTests.cs:113+ assertion:151ApproveParentalLinkCommandHandler.cs:45— explicitExposeAgePublicly = falseSpikerSoft.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 survivesSponsorDtos.cs—SponsorFamilyMember.Age: int?with the consent gate atSponsorFamilyBuilder.cs:373-374ProfileDtos.cs:256—ChildProfileSummaryDto.Age+ mapper atGetChildrenQueryHandler.cs:31Angular half:
user-profile.model.ts:179,197;sponsor.service.ts:83; the toggle correctly nested under@if (child.sponsorshipEnabled)atparent-dashboard.component.html:213-232(data-testid="expose-age-toggle"); card tag atfamily-card.component.ts:97-98; i18n in en + es.Two notes, neither blocking:
TEST-QUALITY-AUDIT.md:3732still says "Thirteen" — stale generated doc, not a code gap.ExposeAgePublicly = falseline satisfies this ticket only. It does not fix #876 (the same handler still clobbersInfoVault); 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.