82 lines
8.5 KiB
Markdown
82 lines
8.5 KiB
Markdown
# Architecture and invariants
|
|
|
|
## Components
|
|
|
|
Parent browser -> same-origin FastAPI service -> SQLite policy, pairing and reward records.
|
|
Child SwiftUI app -> authenticated HTTPS API -> validated snapshot -> local Apple enforcement.
|
|
DeviceActivity extension -> shared App Group state -> named ManagedSettings store.
|
|
|
|
The monitor extension does **not** contact the backend and does not receive its credentials. App/category tokens stay on the phone. Backend truth is limited to policies, submitted challenge results, awarded credit and device-reported status; it is not an independent observation of actual screen use.
|
|
|
|
## Policy inheritance
|
|
|
|
`effective_policy = family_policy + child.overrides` (shallow merge of a validated, flat schema).
|
|
|
|
Empty overrides inherit everything. Clearing an override restores inheritance. Updating shared policy validates every child's resulting policy in the same transaction. A global monotonically increasing revision and `expected_revision` prevent silent lost updates. v0 invalidates outstanding challenges on any policy revision, including a change affecting another child; finer per-child revisions can be added later.
|
|
|
|
The timezone is family-wide. Days and bonus caps are computed by the server using that IANA timezone, not a device-supplied date. The native monitor uses the same timezone. Changes to the timezone after rewards exist are blocked until a proper ledger migration is implemented.
|
|
|
|
Default limits are configurable examples, not scientific or clinical recommendations.
|
|
|
|
## Reward integrity
|
|
|
|
Each challenge contains generated question IDs and server-retained correct answers. The client receives prompts only. Submission must answer every question exactly once with strict integer values; the server checks those values rather than trusting a `completed` flag or client-supplied minutes.
|
|
|
|
An unfinished, unexpired challenge is reused instead of issuing parallel attempts. Attempts expire after ten minutes and at a family-day boundary or policy revision change. A wrong submission is finalized without credit; the child can start another challenge. Attempts are capped at 100 per device/day as a basic resource bound.
|
|
|
|
Submission uses a SQLite `BEGIN IMMEDIATE` transaction. A unique `challenge_id` on rewards plus a persisted challenge result makes retries and concurrent submissions idempotent. The awarded amount is `min(reward_size, remaining_daily_cap)`. Existing grants are summed per child/day and clamped to the current cap in snapshots. There is no client endpoint for arbitrary credit issuance.
|
|
|
|
This verifies correctness, not who did the work. A calculator, another person, or a modified client can solve arithmetic too; v0 does not claim learning-outcome attestation.
|
|
|
|
## Local usage enforcement
|
|
|
|
The app validates the snapshot, preserves usage observations for the current day, and installs a repeating allowed-hours monitor with cumulative usage events. The threshold ladder includes:
|
|
|
|
1. Base allowance and each subsequent reward increment through the cap, including a partial final reward.
|
|
2. The current earned offset and subsequent increments, so a mid-day reward-size change remains representable.
|
|
|
|
Both the ordinary and shifted ladders are registered. On a new day, the ordinary ladder remains valid even without a server connection. Bonus entitlement is only honored on its snapshot day. Observations roll over by policy timezone, not by a foreground-only timer.
|
|
|
|
A callback records the maximum reached threshold. Duplicate or out-of-order lower thresholds do not refund usage. The decision is shielded if outside allowed hours, allowance reached, or monitoring failed. A new ordinary reward just changes entitlement and reevaluates the shield; it does not restart the monitor. Policy/selection changes or lost registration install a new monitor with `includesPastActivity: true`. This is intended to preserve cumulative accounting; exact Apple behavior must be checked on the target OS.
|
|
|
|
Names contain an installation generation so obsolete monitor callbacks cannot overwrite the current plan. A file lock plus atomic JSON replacement serializes app/extension updates. Failed monitor registration leaves the selected apps shielded. Unreadable shared state does not clear existing ManagedSettings. This is defensive behavior, not a guarantee against an OS failing to deliver callbacks. Midnight, policy replacement, late callbacks and restarts are explicit hardware acceptance gates.
|
|
|
|
Only individual apps are supported initially. Whole categories/websites are deferred to avoid expanding enforcement and usage-accounting assumptions before the first physical tests. The app does not promise to override another Screen Time controller or a stricter native limit.
|
|
|
|
## Authentication and synchronization
|
|
|
|
The bootstrap parent token lives in server configuration and is never sent to the child. Parent browser sessions are random, HttpOnly, SameSite=Strict cookies; their stored hash is keyed by the admin secret, so rotating it invalidates sessions. Cookie-authenticated mutations require the configured Origin. Parent API callers may use the bootstrap bearer directly.
|
|
|
|
Pairing/configuration codes carry 144 bits of randomness, expire in ten minutes, are stored hashed, and are consumed atomically. Device bearer tokens are child/device-scoped and hashed at rest on the server. The app stores its token and anchored server origin in Keychain, not UserDefaults or the extension's container. Parent-selection access lasts one short foreground setup session; Apple guardian authorization is requested separately with `.child`, never silently downgraded to individual/self-control authorization.
|
|
|
|
Remote updates apply at the next foreground/manual sync. `status` is a device acknowledgement with a revision, monitoring state, shield flag, bounded detail, and last threshold observation. Parent status deliberately says **last reported**, not independently verified or live. There is no push delivery or retry worker in v0.
|
|
|
|
Revoking pairing disables API access immediately but does not remotely remove local restrictions. The retired device retains its last cached rules. To replace a revoked pairing on the same phone, issue a new pairing code and use Parent setup -> Replace revoked pairing. The phone keeps the old server origin anchored. Server migration/decommissioning needs a designed guardian recovery flow before production use.
|
|
|
|
## API surface
|
|
|
|
| Role | Endpoint | Purpose |
|
|
|---|---|---|
|
|
| Parent | `POST/DELETE /v1/session` | Browser sign-in/sign-out |
|
|
| Parent | `GET /v1/parent/family` | Policy, children, overrides, pairing/status summaries |
|
|
| Parent | `PUT /v1/parent/policy` | Revision-checked shared policy replacement |
|
|
| Parent | `POST /v1/parent/children` | Create a child display-name profile |
|
|
| Parent | `PUT /v1/parent/children/{id}/overrides` | Replace sparse child overrides |
|
|
| Parent | `POST /v1/parent/children/{id}/pairing-code` | Issue a one-use enrollment code |
|
|
| Parent | `POST /v1/parent/devices/{id}/configuration-code` | Permit one selection-editing session |
|
|
| Parent | `DELETE /v1/parent/devices/{id}` | Revoke API access |
|
|
| Enrollment | `POST /v1/pair` | Consume code; return token once |
|
|
| Device | `POST /v1/device/configuration-unlock` | Consume a device-specific parent code |
|
|
| Device | `GET /v1/device/snapshot` | Validated effective policy and today's bonus |
|
|
| Device | `POST /v1/device/status` | Report local applied/error status |
|
|
| Device | `POST /v1/device/challenges` | Start/reuse generated challenge |
|
|
| Device | `POST /v1/device/challenges/{id}/submit` | Grade and issue bounded, idempotent reward |
|
|
|
|
Snapshot v1 fields: `schema_version`, `device_id`, `child_id`, `child_name`, `policy_revision`, `policy`, `day`, `earned_minutes`, `server_time` (Unix seconds). JSON uses snake_case, with shared Swift encoder/decoder conventions. Policy fields are listed in `backend/src/apc/models.py`; `packages/PolicyCore` implements their native validation and semantics.
|
|
|
|
Submission result fields: `challenge_id`, `passed`, `correct_count`, `required_count`, `awarded_minutes`, nullable `reward_id`, and `day`. A credit being recorded is distinct from a subsequent successful snapshot application.
|
|
|
|
## Planned extensions
|
|
|
|
The narrow first slice is deliberately not a general quest engine. Add manual chores/approval and an explicit grant ledger API after iPhone enforcement is validated. Each new reward source must preserve idempotency, cap handling and the exact threshold ladder; arbitrary reward denominations need extra accounting work. Then add background synchronization, richer schedules and safe recovery. Do not introduce per-device copies of the family policy as a shortcut.
|