The progress feature tracks a per-child star count and unlocked milestones,
earned as a side effect of completing tasks in the Calendars feature. A
guardian configures the ordered list of goal posts (threshold, icon, optional
label) a child progresses through — see
Configurable goal posts for progress —
and a per-child reward catalog the child spends stars on. The child asks for a
reward and a guardian approves or declines; only an approval spends stars, and
lifetime stars (which drive the goal posts) never drop — see
Reward redemption for progress.
The only way a ChildProgress aggregate changes from stars is an internal
call from SetTaskCompletionHandler, made after that handler's own event has
already been appended; goal posts, rewards and requests change directly, via
the endpoints below.
sequenceDiagram
actor Child
actor Guardian
participant App as Client app
participant API as Buddy API
participant Calendars as Calendars feature
participant Progress as Progress feature
participant Store as Progress event store
Child->>App: Mark a task complete
App->>API: PATCH /calendars/{calendarId}/items/{itemId}[/subtasks/{subtaskId}]/completion
API->>Calendars: SetTaskCompletion command
Calendars->>Calendars: Append TaskCompletionChanged
alt Item has an assignee
Calendars->>Progress: RecordStarChange (internal, via IMessageBus)
Progress->>Store: Read child's stream, rehydrate ChildProgress
Store-->>Progress: Existing events, or none
Progress->>Store: Append StarAwarded/StarRevoked, maybe MilestoneUnlocked
Progress-->>Calendars: Success, or a swallowed failure
end
Calendars-->>API: Updated CalendarItem
API-->>App: 200 OK
Child->>App: View their own progress
App->>API: GET /progress/me
API->>Progress: GetMyProgress query
Progress->>Store: Read caller's stream and rehydrate
Store-->>Progress: ChildProgress, or none
Progress-->>API: ProgressSummary
API-->>App: 200 OK
Guardian->>App: View a child's progress
App->>API: GET /progress/children/{childId}
API->>Progress: GetChildProgress query
Progress->>Progress: Check self or active guardian
Progress->>Store: Read child's stream and rehydrate
Store-->>Progress: ChildProgress, or none
Progress-->>API: ProgressSummary
API-->>App: 200 OK
Guardian->>App: Configure a child's goal posts
App->>API: PUT /progress/children/{childId}/goals
API->>Progress: ConfigureGoalPosts command
Progress->>Progress: Check active guardian (Manage tier)
Progress->>Store: Read child's stream, rehydrate ChildProgress
Store-->>Progress: Existing events, or none
Progress->>Store: Append GoalPostsConfigured (creating the stream first, if needed)
Progress-->>API: ProgressSummary
API-->>App: 200 OK
sequenceDiagram
actor Child
actor Guardian
participant API as Buddy API
participant Progress as Progress feature
participant Store as Progress event store
Guardian->>API: PUT /progress/children/{childId}/rewards
API->>Progress: ConfigureRewards (Manage)
Progress->>Store: Append RewardsConfigured (full replace, stable reward ids)
Child->>API: POST /progress/me/reward-requests { rewardId }
API->>Progress: RequestReward (the caller's own stream only)
Progress->>Store: Read and rehydrate ChildProgress
alt cost <= spendable stars, under 10 pending
Progress->>Store: Append RewardRequested (reserves the cost)
Progress-->>API: 200 ProgressSummary
else
Progress-->>API: 409 insufficient_stars / too_many_pending_requests
end
Guardian->>API: POST /progress/children/{childId}/reward-requests/{requestId}/approve
API->>Progress: ApproveRewardRequest (Manage)
Progress->>Store: Read and rehydrate ChildProgress
alt still pending and cost <= TotalStars - SpentStars
Progress->>Store: Append RewardRequestApproved (SpentStars += cost)
else
Progress-->>API: 409 insufficient_stars / reward_request_resolved
end
Declining (/decline, guardian) and cancelling (/progress/me/reward-requests/{requestId}/cancel,
the child) follow the approve shape without the star check, and release the reservation.
| Method | Route | Behavior |
|---|---|---|
GET |
/progress/me |
Returns the caller's own star count, unlocked milestones, and resolved goal-post info. Self-only: it always resolves the child from the caller's own claims. |
GET |
/progress/children/{childId} |
Returns one named child's star count, unlocked milestones, and resolved goal-post info. Read-only guardian-facing view; see "Authorization model" below. |
PUT |
/progress/children/{childId}/goals |
Guardian-only. Replaces the child's full ordered list of goal posts (Threshold, Icon, optional Label). Full-replace, not a partial update; see "Authorization model" below. |
PUT |
/progress/children/{childId}/rewards |
Guardian-only. Replaces the child's reward catalog (Name, Icon, Cost, and the Id of a reward being kept). An unknown Id is 400. |
POST |
/progress/me/reward-requests |
The caller asks for one of their own rewards. 409 insufficient_stars past the spendable balance, 409 too_many_pending_requests at 10 pending, 404 for a reward not in the catalog. |
POST |
/progress/me/reward-requests/{requestId}/cancel |
The caller withdraws their own pending request. |
POST |
/progress/children/{childId}/reward-requests/{requestId}/approve |
Guardian-only. Spends the request's cost; 409 insufficient_stars if revoked stars left too few. |
POST |
/progress/children/{childId}/reward-requests/{requestId}/decline |
Guardian-only. Releases the reservation. |
Every route returns the updated ProgressSummary, which also carries SpendableStars, SpentStars,
the catalog and the reward requests (pending first, then the 20 most recently resolved). Repeating the
same resolution is an idempotent 200; resolving an already-resolved request differently is
409 reward_request_resolved.
RecordStarChange (see "Core lifecycle") is still an internal command, not
an HTTP endpoint — a star is never awarded or revoked by a client calling
Progress directly.
Every rule the endpoints enforce, validator rules first, then the handler's own checks.
A 400 validation_error carries the message in the ErrorEnvelope's details under the
key in the Field column, "" for a check that isn't tied to one field (see
HTTP status codes). A body
that can't be bound is rejected before these rules run, with the same envelope.
| Endpoint | Field | Rule | Code | Status |
|---|---|---|---|---|
ConfigureGoalPosts |
goalPosts |
Required: at least one goal post. | validation_error |
400 |
ConfigureGoalPosts |
goalPosts |
At most 20 goal posts. | validation_error |
400 |
ConfigureGoalPosts |
goalPosts |
Thresholds strictly increasing, in list order (no duplicates). | validation_error |
400 |
ConfigureGoalPosts |
goalPosts[n].threshold |
Greater than 0. | validation_error |
400 |
ConfigureGoalPosts |
goalPosts[n].icon |
Required: not empty after trimming. At most 32 characters after trimming. | validation_error |
400 |
ConfigureGoalPosts |
goalPosts[n].label |
At most 100 characters after trimming. Optional (null or blank means none). | validation_error |
400 |
ConfigureGoalPosts |
"" |
No null entries in goalPosts (checked by the endpoint, before the validator). |
validation_error |
400 |
ConfigureRewards |
rewards |
At most 30 rewards. An empty list is allowed (removes every reward). | validation_error |
400 |
ConfigureRewards |
rewards |
Each Id at most once (rows without an Id are new rewards). |
validation_error |
400 |
ConfigureRewards |
rewards[n].name |
Required: not empty after trimming. At most 100 characters after trimming. | validation_error |
400 |
ConfigureRewards |
rewards[n].icon |
Required: not empty after trimming. At most 32 characters after trimming. | validation_error |
400 |
ConfigureRewards |
rewards[n].cost |
Between 1 and 10,000 inclusive. | validation_error |
400 |
ConfigureRewards |
"" |
No null entries in rewards (checked by the endpoint, before the validator). |
validation_error |
400 |
ConfigureRewards |
rewards[n].id |
A kept reward's Id must be in the child's current catalog. Checked after authorization; the first unknown one is reported. |
validation_error |
400 |
RequestReward |
— | No rules beyond binding the body (rewardId). A reward not in the caller's catalog is 404. |
validation_error |
400 |
RequestReward |
— | At most 10 pending requests per child. Checked before the star balance. | too_many_pending_requests |
409 |
RequestReward |
— | The reward's cost must not exceed the spendable stars (lifetime stars minus spent stars minus pending reservations). | insufficient_stars |
409 |
CancelRewardRequest, ApproveRewardRequest, DeclineRewardRequest |
— | The request must still be pending; repeating the same resolution is an idempotent 200, a different one is rejected. |
reward_request_resolved |
409 |
ApproveRewardRequest |
— | The request's cost must not exceed lifetime stars minus spent stars (a revoked star may have eaten into the reservation). | insufficient_stars |
409 |
RecordStarChange is not an HTTP endpoint. It is an internal command,
invoked via IMessageBus.InvokeAsync from SetTaskCompletionHandler in the
Calendars feature, after TaskCompletionChanged has already been appended
to the calendar item's own stream, and only when the item has an assignee
(item.AssignedTo). The call is wrapped in a try/catch that deliberately
swallows a failure: the task completion itself has already succeeded and is
not rolled back, so a failed star update just leaves the child's count stale
until the next successful, idempotent completion change catches it up. This
mirrors the two-write, not-a-transaction shape the GuardianLink decision
already established for cross-feature calls elsewhere in this codebase.
ChildProgress is an event-sourced aggregate with one stream per child, keyed
by ProgressId.Value == ChildId.Value. Unlike most aggregates in this
codebase, no index document maps a domain ID to a child — the relationship is
already 1:1, so the child's own UserId is reused directly as the stream ID.
The stream is created lazily on the first star (or the first goal-post
configuration, if that happens before any star does), not when the child
account is provisioned. It contains:
ProgressStarted, appended once, establishing the aggregate and its child.StarAwarded, appended on afalse→truecompletion transition, keyed by anOccurrenceKey(ItemId, OccurrenceDate, Target), whereTargetis aCompletionTarget: the whole task, or one subtask.StarRevoked, appended on atrue→falsetransition for the same key, mirroringTaskCompletionChanged's ownBefore/Aftertoggle semantics rather than a separate "undo" event.MilestoneUnlocked, appended whenTotalStarsnewly reaches a threshold resolved byGoalPostResolver— the child's own configured goal posts if any exist, extrapolated indefinitely past the last one, or else the original fixed[5, 10, 25, 50, 100]scale — checked at write time.GoalPostsConfigured, appended by the guardian-onlyConfigureGoalPostscommand, carrying the complete replacement list of goal posts (full replace, not a partial update — see configurable-goal-posts.md).RewardsConfigured, the guardian's full-replace reward catalog.RewardRequested, the child's request, with the reward's name, icon and cost copied at request time; pending requests reserve their cost.RewardRequestApproved(adds the cost toSpentStars),RewardRequestDeclinedandRewardRequestCancelled, each resolving one pending request.
AwardedOccurrences is a sparse set of OccurrenceKey (item, occurrence
date, and completion target), so a plain task and each independently-completable subtask
of a template-scheduled task earn and revoke their own star. Re-awarding an
already-awarded key, or re-revoking a key that isn't currently awarded, is a
no-op: RecordStarChangeHandler compares command.IsCompleted against
whether the occurrence is already in AwardedOccurrences before appending
anything, the same before/after guard SetTaskCompletionHandler already
applies to TaskCompletionChanged.
All endpoints require an authenticated caller. ProgressAuthorization
resolves a two-tier access level, mirroring MedicineAuthorization's
Mark/Manage split:
- View — the child identified by
childId(or, for/progress/me, the caller themself) can always view their own progress; an active guardian of that child can also view it. - Manage — only an active guardian can configure goal posts and rewards
and approve or decline requests. A child attempting any of these on themself
has View but not Manage, so the endpoint returns
403 Forbiddenrather than404. - Self — requesting and cancelling go through
/progress/me, so they only ever reach the caller's own stream; nochildIdis accepted.
A caller with no relationship to the child at all (neither View nor Manage)
receives 404 Not Found, collapsing "no such child" and "not your child"
the same way other domains in this codebase already do. A child or guardian
with no progress stream yet (nothing completed, no goal posts configured)
receives 200 OK with zero stars and no milestones, never 404 Not Found
— an empty progress history is not an error.
ProgressStarted— starts a child's stream on their first completion or first goal-post configuration.StarAwarded— one star for one(ItemId, OccurrenceDate, Target).StarRevoked— removes the star for that same key.MilestoneUnlocked— records a goal-post threshold newly crossed.GoalPostsConfigured— guardian-authored, full-replace list of goal posts (Threshold,Icon, optionalLabel).RewardsConfigured— guardian-authored, full-replace reward catalog.RewardRequested/RewardRequestApproved/RewardRequestDeclined/RewardRequestCancelled— a reward request and how it was resolved.
See Gamified progress for children's tasks for the design rationale behind the dedicated aggregate, the explicit synchronous call instead of a projection, and the 1:1 stream-ID shortcut, and Configurable goal posts for progress for the guardian-write goal-post design covered above, and Reward redemption for progress for the reward catalog and requests. Dose gamification, manual star adjustment, and sibling comparisons remain out of scope.