# First implementation contracts — draft v0.1

11 September 2026 · Concrete engineering handoff for review, not an implemented or jointly approved API.

Agree this narrow boundary before two builders start. Do not specify every launch endpoint upfront. The first increment proves a child’s memory can be saved and found again; the next proves two parents can safely change a care agreement. Both use one authenticated family/access model. [Full scope](10-final-mvp-scope.md) remains authoritative if a draft below conflicts.

## 1. Ownership and conventions

Lead owns the eventual `packages/contracts/`, root configuration, lockfile and integration. Backend owns `supabase/` and server tests; native frontend owns `apps/mobile/`. These folders are proposed, not created here. Once reviewed, encode the following in shared TypeScript/runtime schemas and fixtures; both workers import those definitions. Use separate Git worktrees after repository initialization.

All `/v1` routes require an authenticated session. The server derives the actor and checks active family membership and record visibility on every operation. IDs in the route/body are selectors, never access grants. Never accept client-supplied author IDs, storage paths, recipient authorization or agreement status. Auth/recovery use the chosen provider SDK, not a second password service.

- Opaque UUID identifiers; examples below use readable fixture aliases only.
- `createdAt` / `updatedAt` are UTC ISO timestamps. A memory’s `occurredOn` is a `YYYY-MM-DD` date in family context, not a midnight UTC timestamp. Care start/end are offset-bearing instants; keep an IANA family timezone.
- Versioned mutable records start at integer `version: 1`. Writes that edit existing state require the expected version. Server-generated IDs/timestamps/versions are authoritative.
- Successful create returns 201 with the record; read/update returns 200. Lists return `{ items, nextCursor }`, with opaque stable cursors. Use a bounded limit, default 20, maximum 50.
- Error envelope: `{ error: { code, message, fieldErrors?, currentVersion?, retryable, traceId } }`. Stable codes drive friendly native UI; do not expose database errors, other-family values, tokens or family content in logs.
- Codes include `UNAUTHENTICATED` (401), `NOT_FOUND` (404, also for inaccessible records), `FORBIDDEN` (403 for a visible record with a disallowed action), `VALIDATION_FAILED` (422), `VERSION_CONFLICT` / `INVALID_STATE` / `IDEMPOTENCY_CONFLICT` (409), `LIMIT_REACHED` (422), and `TEMPORARY_FAILURE` (503). A conflict may expose the current version only after access checks.

## 2. Family, children and Home

| Route | Result / rule |
| --- | --- |
| `GET /v1/me` | Actor plus active family memberships; no unrelated-family enumeration |
| `GET /v1/families/:familyId` | Name, timezone, membership mode and audience summary visible to this actor |
| `GET /v1/families/:familyId/children` | Child IDs, preferred names, optional authorized avatar and practical profile version |
| `POST /v1/families/:familyId/children` | `{ preferredName, birthDate? }`; validates membership and scoped uniqueness of the idempotent operation |
| `GET /v1/families/:familyId/home` | Authorized children, latest accessible memory, next agreed handover, incoming attention summary; nullable/empty values are normal |

The onboarding slice creates a family/initial membership through one server transaction, then children. Define its provider-specific bootstrap implementation with the auth scaffold; a client cannot insert itself into an arbitrary family. Display ages derived from an optional birth date rather than persisting a stale age.

Home returns links/IDs to canonical records, not a second copy of agreements or questions. Memory prominence must not change plan authority. A solo Home offers a first-memory action and an optional invitation, with no invented handover or urgent count.

## 3. Memory record and first write

Proposed persisted shape:

```typescript
type Memory = {
  id: string;
  familyId: string;
  childIds: string[]; // non-empty, distinct, all from this family
  authorId: string; // server derived
  title: string | null; // optional, max 90 characters
  story: string | null; // max 2000 characters
  occurredOn: string; // valid local calendar date, not in future
  category: 'moment' | 'milestone' | 'birthday' | 'school' | 'sport' | 'holiday' | 'other';
  visibility: 'private' | 'family';
  asset: MediaAsset | null; // one attachment
  version: number;
  createdAt: string;
  updatedAt: string;
};
type MediaAsset = {
  id: string;
  kind: 'photo' | 'video';
  state: 'uploading' | 'processing' | 'ready' | 'failed';
  width: number | null;
  height: number | null;
  durationMs: number | null;
};
```

Require non-blank story OR one authorized attachment, not a title. For display use explicit title, otherwise a short story excerpt, otherwise “A little moment”; retain `title: null` so a generated display fallback does not become authored content. Categories drive the milestone collection; no AI classification. Scope media limits are server-configured and still provisional.

| Route | Input / result |
| --- | --- |
| `POST /v1/families/:familyId/memories` | `{ childIds, title?, story?, occurredOn, category, visibility, assetId? }` → Memory |
| `GET /v1/families/:familyId/memories?childId=&month=&category=&cursor=&limit=` | Accessible records only, descending occurredOn then createdAt then ID; `month` is YYYY-MM |
| `GET /v1/families/:familyId/memories/:id` | Authorized Memory |
| `PATCH /v1/families/:familyId/memories/:id` | Editable fields plus expectedVersion; author only, no author/family reassignment |
| `DELETE /v1/families/:familyId/memories/:id` | expectedVersion; author only; 202 deletion job, immediately remove from normal reads |

Filtering happens on the server before pagination and counts. Chapter covers/counts must use the same authorized child filter; never infer totals from one partially loaded page. Month/milestone summary read models can be added when implementing M3 pagination.

`private` is author-only. `family` is visible to the members authorized by the family sharing model. The first connected-family fixture names both recipients explicitly. In solo mode family sharing is unavailable: create private records and show the future invitation disclosure. Joining a family never silently turns private records into shared ones. Invitation selection/publish and departure rules must follow scope section 4 before real accounts connect; this draft is not a substitute for those workflows.

## 4. Attachment handshake

The first installed increment supports photo/text. `video` is reserved in the shared asset type and remains in launch scope; implement it after the real-phone media spike, not through a fake working upload button.

1. `POST /v1/families/:familyId/uploads` with `{ kind, sourceBytes, declaredMime }` returns `{ assetId, uploadUrl, expiresAt, sourceByteLimit }`. Reserve quota and create a private staging object owned by this actor. Treat declared size/type as untrusted.
2. Upload bytes using the short-lived provider URL. No storage credentials in the client. URL issuance is scoped to one object and a bounded lifetime.
3. `POST /v1/families/:familyId/uploads/:assetId/complete` queues verification/processing and returns the asset state. The server verifies stored bytes and decoded format/limits; a client cannot mark an asset ready.
4. `GET /v1/families/:familyId/uploads/:assetId` lets the uploader recover progress. An attachment can bind to only one memory, by its owner. While processing, readers see a processing placeholder; no unverified original is served. A failed attachment leaves the written memory recoverable and provides retry/remove actions.
5. `POST /v1/families/:familyId/memories/:id/media-access` returns short-lived authorized derivative URLs and expiry for a ready asset. Re-check memory visibility, including posters. Persistent records never contain public bucket URLs.

Client state: selected → uploading → processing → saved/ready, or failed with a recoverable draft. A memory record can persist before media is ready, but the UI must not claim the photo is ready or successfully delivered. Cancelled/orphaned uploads expire and release quota; deletion removes derivatives and source objects through a retried job. Protected URL lifetime and cache behaviour must be tested against revocation requirements before the pilot.

## 5. Second increment: one care change

CareBlock contains `{ id, familyId, childIds, startsAt, endsAt, householdId, handoverAt, timezone, version }`. Validate start < end, valid child/household membership and a handover inside the block. Seed one **already confirmed** block for the initial internal fixture. Do not label a solo-entered plan mutually agreed; production initial shared-plan confirmation remains required.

Request contains `{ id, familyId, careBlockId, authorId, recipientId, expectedCareVersion, proposedHandoverAt, status, expiresAt, version }`. Status is `pending | accepted | declined | cancelled | expired | superseded`. Server chooses the eligible other parent/expiry according to the scope rules; the client cannot nominate an arbitrary recipient or grant authority.

| Route | Behaviour |
| --- | --- |
| `GET /v1/families/:familyId/care-blocks/:id` | Returns the effective agreed record |
| `POST /v1/families/:familyId/requests` | `{ careBlockId, expectedCareVersion, proposedHandoverAt }`; validate before creating pending proposal |
| `GET /v1/families/:familyId/requests/:id` | Current request plus authorized current-versus-proposed context |
| `POST /v1/families/:familyId/requests/:id/respond` | `{ expectedRequestVersion, action: 'accept' | 'decline' }`; recipient only |

Accept in one transaction: validate actor, pending/unexpired request, expected request version and care version; update the agreed block; mark accepted; increment versions; append history and outbox event. A stale care version returns VERSION_CONFLICT without applying the proposed change. Decline updates the request/history only. Author cancellation, counteroffers, recurrence and holidays follow as later increments within the agreed launch scope; their routes are not guessed here.

Fixture: agreed Friday 11 September 2026 at 17:00 Europe/Amsterdam (`2026-09-11T15:00:00Z`); proposal 18:00 (`2026-09-11T16:00:00Z`). Inject the clock in tests so this dated fixture remains repeatable. Pending never replaces agreed time on Home or Calendar.

## 6. Retries, conflicts and acceptance fixtures

Mutating creates/transitions/upload completion require an `Idempotency-Key`. Scope it to actor + route + operation, retain a normalized request-body hash and the successful response transactionally. Concurrent duplicates execute once. Retry with the same key/body returns that result; a different body yields IDEMPOTENCY_CONFLICT. Re-check authorization before replay. Specify retention/expiry in the implementation schema and client retry policy together; do not promise indefinite replay. A version conflict requires refetch/review, never automatic overwrite. Do not queue a request acceptance blindly while offline.

Before expanding the API, both workers use the same fixtures:

- Solo A creates child and text-only memory without a title; restart preserves it and child filtering finds it.
- A photo-only memory completes processing, can be reopened, and exports through the later authorized export workflow; a failed upload retains its written draft.
- Connected A/B can read a deliberately shared memory; B cannot read A’s private memory; unrelated C gets no content, cover, count or media URL.
- Editing/deleting another author’s memory is rejected; duplicate creation does not produce duplicate memories; a stale edit does not overwrite a newer edit.
- B accepts A’s proposed 18:00 handover; both sessions/restart show 18:00 and one history transition. Decline leaves 17:00. Duplicate acceptance is harmless; stale/expired/wrong-actor responses cannot change the plan.
- Native screens distinguish loading, empty, retry, processing, conflict and committed success, with no false success after connection loss.

## 7. Recommended next session

One lead session reviews this draft, settles the auth/bootstrap and invitation visibility schemas, encodes contracts/fixtures, initializes Git and creates the native scaffold. Then run **two builders: backend + native frontend** against those versioned files. Integrate the child/memory loop before broadening endpoints, then the care-change loop. Re-estimate from the running device build. No extra agents or production services were started by this document.
