Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/capture-recovery/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# @journal/capture-recovery

Interrupted-capture recovery for the Shared Child Journal: a pure, deterministic
state machine + truthful UI projection + replayable fixtures covering
**interruptions (call / app switch), backgrounding with process death, network
loss, and duplicate submission** — prototyped one-handed for mom, dad, and
invited caregivers.

Bounded direct-worker extension of the canonical contract
(`art_I2TCG08V` v0.2). It **imports** `CaptureId`, `ExtractionAttempt`, and
`ExtractionStatus` from `@journal/domain` and **modifies nothing** in the
existing domain, entry, event, extraction, ui, security, or evaluation
packages. Proposal target: fold into contract v0.3 if accepted.

## Why events, not states

Interruption, backgrounding, network loss, and duplicate submission are
**events**, not phases. The persisted state is always sufficient to resume, so
recovery is just rehydration — no special "recovering" phase, no re-processing.

## Phases (truthful by construction)

| Phase | Means | Can never mean |
| --- | --- | --- |
| `drafting` | capture in progress; raw transcript persisted on every change | — |
| `pending` | submit in flight; server outcome genuinely unknown | "sent" / "saved" |
| `saved` | server ack received for the current submission | optimistic |
| `failed` | explicit server rejection with a reason; raw transcript intact | network guess without a rejection |
| `discarded` | caregiver explicitly discarded; receipt kept, raw cleared | silent absence |

There is no optimistic `saving` phase. `saved` is reachable **only** through
`SubmitAccepted` (idempotent: a repeated accept returns the same state
object). During a network outage a pending capture stays `pending` — the
client never fabricates a failure or a save.

## Events

`TranscriptChanged{at,text}` · `Interrupted{at,kind: call|app-switch|background|process-death}` ·
`Resumed{at}` · `SubmitRequested{at,submissionId}` · `RetryRequested{at}` ·
`SubmitAccepted{at}` · `SubmitRejected{at,attempt,reason: network|server|unauthorized|invalid}` ·
`ExtractionResultArrived{at,attempt,outcome: structured|failed}` ·
`NetworkLost{at}` · `NetworkRestored{at}` · `DiscardRequested{at}`

The reducer is total: unexpected event/phase combinations are recorded as
bounded anomalies (cap 10) instead of throwing — a recovery path must never
crash the capture it is recovering. Timestamps come from events only, so
fixtures replay identically anywhere.

## Invariants (each pinned by a test)

1. **Raw-before-events** — only `TranscriptChanged` and an explicit
`DiscardRequested` may touch `rawTranscript`; every other transition
preserves it verbatim.
2. **Attempt monotonicity** — `attempt` never decreases (canonical envelope).
3. **Stable submission identity** — `submissionId` is assigned at first submit
and reused across retries; a foreign id while active is an anomaly.
4. **No optimistic truth** — `saved` only via `SubmitAccepted`; duplicate
submissions suppressed while pending AND after save.
5. **Stale-result suppression** — results/rejections with
`attempt < current` are counted and discarded, never merged (contract
v0.2 rule b).
6. **Discard leaves a receipt** — raw text cleared; the receipt (time,
length, attempt, submissionId) survives, keeping a discarded capture a
visible receipt rather than a missing log (missing-log vs zero-care).
7. **Audience stays out** — phases describe delivery of the capture, never
who may see it; `Entry.visibility` (`draft|published`) remains the only
per-entry publication state, and household grants remain the audience
dimension (contract v0.2, two independent dimensions).

## One-handed truthful UI projection (`bannerFor`)

Every banner is **derived from persisted state** — never in-memory optimism.
Bottom-anchored (thumb reach), ≤ 2 actions, single 48pt primary target.

| Persisted state | Banner |
| --- | --- |
| `drafting` + interrupted | info — "Draft is safe on this device" / primary **Keep recording** |
| `pending` | info — "Sending…" / "Not saved to the journal yet. A copy stays on this device." |
| `pending` + network lost | warning — "Waiting for network" / no actions (nothing to truthfully tap) |
| `failed` network/server | warning — "Couldn't send — …" / **Try again** + **Discard** |
| `failed` unauthorized | warning — "Sign-in needed" / **Sign in & send** + **Discard** |
| `failed` invalid | warning — "Needs a fix before sending" / **Review** + **Discard** |
| `saved` | banner hidden; success chip "Saved to the journal" |
| `discarded` | neutral receipt — "Discarded — nothing was added to the journal" |

## Storage contract

Save-on-transition: the caller persists after **every** `reduce` (the reducer
itself is pure and does no I/O), so a process death at any instant loses
nothing. `CaptureStorage` exposes `load` (cold-start rehydration),
`listUnresolved` (drafting/pending/failed — the reconnect sweep reconciles
these by `submissionId`), and `listReceipts` (discarded — receipts, not
unresolved work). Durable adapters must read through `decodeStoredState`
(fail-closed decode before use).

## Fixture catalog (`fixtures/`, replayed by `test/recovery.test.ts`)

| # | Fixture | Scenario |
| --- | --- | --- |
| 01 | `interruption-mid-draft` | call interrupts; reload mid-draft; raw verbatim; completes → saved |
| 02 | `backgrounding-process-death` | background + process death; cold start rehydrates raw + attribution → saved |
| 03 | `network-loss-pending` | outage after submit; stays pending (no fabrication); ack after restore → saved |
| 04 | `failed-retry-success` | explicit rejection → failed; one-handed retry (same submissionId, attempt+1) → saved |
| 05 | `duplicate-submission` | double tap + late re-tap; 2 duplicates suppressed; duplicate ack idempotent |
| 06 | `stale-result` | superseded attempt-0 result suppressed, never merged; attempt-1 applies |
| 07 | `discard-receipt` | explicit discard; receipt survives reload; resurrection attempt is an anomaly |

```bash
# from the repo root
pnpm install
pnpm exec turbo run build typecheck test --filter=@journal/capture-recovery
```

## Ownership and handoff

This package is a **proposal, implemented as code**: state-transition
contracts + fixtures, ready to be consumed by the lanes that own the
integration.

- **Slot 22 — Offline capture / reconnect / concurrent contributions**
(`todo_N1ukao5q`, Journal & Caregiver Coordination): adopt this machine as
the capture-side recovery kernel; add the durable `CaptureStorage` adapter
(AsyncStorage/SQLite) and the pending-reconciliation protocol.
- **Voice/photo capture slice (merged PR #8)**: wire RN app-lifecycle events
to `Interrupted`/`Resumed`, the speech layer to `TranscriptChanged`, and
the Convex mutation to `SubmitRequested`/`SubmitAccepted`/`SubmitRejected`.
- **Review-UX candidates (slots 11–13)**: `bannerFor`/`RecoveryBanner` is the
projection contract to render; copy strings are pinned by tests.
- **Contract thread (`art_I2TCG08V`)**: propose folding the recovery state
schema into contract v0.3 — no Entry/Event/field changes are required by
this package.

## Known limitations (honest)

- No live React Native component is included: the on-screen banner rendering
and the AsyncStorage adapter are deliberately left to the owning lanes
(above); what ships here is the behavior contract they consume.
- Pending-reconciliation (resolving a `pending` capture against the server by
`submissionId` after a cold start) is a named integration responsibility
here, not an implemented Convex function — it belongs with slot 02's
operation contracts and slot 22's implementation.
- Attachments (photo recovery, large blobs) are out of scope for this slice.
- The reducer trusts the caller to persist on transition; runtime enforcement
belongs to the storage adapter integration.
24 changes: 24 additions & 0 deletions packages/capture-recovery/fixtures/01-interruption-mid-draft.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id": "interruption-mid-draft",
"kind": "interruption-recovery",
"description": "A phone call interrupts mid-dictation. The raw transcript was persisted before the interruption, so after reload the draft is recoverable verbatim and can be completed one-handed.",
"capture": { "captureId": "cap-recover-001", "authorId": "caregiver-1", "at": 1789563600000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789563601000, "text": "Ava had a huge meltdown" } },
{ "event": { "_tag": "TranscriptChanged", "at": 1789563602000, "text": "Ava had a huge meltdown after preschool" } },
{ "event": { "_tag": "Interrupted", "at": 1789563603000, "kind": "call" } },
{ "reload": true },
{ "event": { "_tag": "Resumed", "at": 1789563630000 } },
{ "event": { "_tag": "TranscriptChanged", "at": 1789563631000, "text": "Ava had a huge meltdown after preschool, calmed by her plush rabbit." } },
{ "event": { "_tag": "SubmitRequested", "at": 1789563640000, "submissionId": "sub-recover-001" } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789563645000 } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"rawTranscript": "Ava had a huge meltdown after preschool, calmed by her plush rabbit.",
"submissionId": "sub-recover-001",
"attempt": 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"id": "backgrounding-process-death",
"kind": "interruption-recovery",
"description": "The app is backgrounded mid-dictation and the OS kills the process. On relaunch, the draft rehydrates from persisted state alone — raw text intact, author attribution intact — and the caregiver completes the capture.",
"capture": { "captureId": "cap-recover-002", "authorId": "caregiver-1", "at": 1789563700000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789563701000, "text": "Ava ate all of her pasta at dinner" } },
{ "event": { "_tag": "Interrupted", "at": 1789563702000, "kind": "background" } },
{ "reload": true },
{ "event": { "_tag": "Resumed", "at": 1789563760000 } },
{ "event": { "_tag": "SubmitRequested", "at": 1789563761000, "submissionId": "sub-recover-002" } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789563764000 } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"rawTranscript": "Ava ate all of her pasta at dinner",
"attempt": 0
}
}
18 changes: 18 additions & 0 deletions packages/capture-recovery/fixtures/03-network-loss-pending.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"id": "network-loss-pending",
"kind": "network-loss",
"description": "The network drops right after submit. The truthful contract: the capture stays pending — the server outcome is genuinely unknown and the client must not fabricate a failure or a save. When connectivity returns, the server ack (it completed despite the disconnect) lands and the capture is saved.",
"capture": { "captureId": "cap-recover-003", "authorId": "caregiver-1", "at": 1789563800000 },
"steps": [
{ "event": { "_tag": "SubmitRequested", "at": 1789563801000, "submissionId": "sub-recover-003" } },
{ "event": { "_tag": "NetworkLost", "at": 1789563802000 } },
{ "event": { "_tag": "NetworkRestored", "at": 1789563840000 } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789563841000 } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"attempt": 0
}
}
24 changes: 24 additions & 0 deletions packages/capture-recovery/fixtures/04-failed-retry-success.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"id": "failed-retry-success",
"kind": "network-loss",
"description": "A submission is explicitly rejected (network). The capture moves to failed with the raw transcript intact; a one-handed retry reuses the SAME submissionId (idempotent server replay) and bumps the extraction attempt (canonical envelope). The retry is accepted.",
"capture": { "captureId": "cap-recover-004", "authorId": "caregiver-1", "at": 1789563900000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789563901000, "text": "Mia tried the big slide all by herself" } },
{ "event": { "_tag": "SubmitRequested", "at": 1789563902000, "submissionId": "sub-recover-004" } },
{ "event": { "_tag": "SubmitRejected", "at": 1789563903000, "attempt": 0, "reason": "network" } },
{ "event": { "_tag": "NetworkRestored", "at": 1789563933000 } },
{ "event": { "_tag": "RetryRequested", "at": 1789563934000 } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789563937000 } },
{ "event": { "_tag": "ExtractionResultArrived", "at": 1789563940000, "attempt": 1, "outcome": "structured" } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"rawTranscript": "Mia tried the big slide all by herself",
"submissionId": "sub-recover-004",
"attempt": 1,
"extractionStatus": "structured"
}
}
22 changes: 22 additions & 0 deletions packages/capture-recovery/fixtures/05-duplicate-submission.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"id": "duplicate-submission",
"kind": "duplicate-submission",
"description": "Double-tap and late re-tap submit the same capture three times with one stable submissionId. The machine suppresses duplicates while pending AND after save; duplicate server acks are idempotent no-ops. Exactly one saved capture results.",
"capture": { "captureId": "cap-recover-005", "authorId": "caregiver-1", "at": 1789564000000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789564001000, "text": "Leo had two naps at school today" } },
{ "event": { "_tag": "SubmitRequested", "at": 1789564002000, "submissionId": "sub-recover-005" } },
{ "event": { "_tag": "SubmitRequested", "at": 1789564002100, "submissionId": "sub-recover-005" } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789564003000 } },
{ "event": { "_tag": "SubmitRequested", "at": 1789564003100, "submissionId": "sub-recover-005" } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789564004000 } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"rawTranscript": "Leo had two naps at school today",
"duplicateSubmissionsSuppressed": 2,
"attempt": 0
}
}
23 changes: 23 additions & 0 deletions packages/capture-recovery/fixtures/06-stale-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"id": "stale-result",
"kind": "stale-result",
"description": "After a failed attempt 0 the caregiver retries (attempt 1). The superseded attempt 0 extraction result arrives late and is suppressed — discarded, never merged (contract v0.2 rule b). The latest attempt 1 result applies normally.",
"capture": { "captureId": "cap-recover-006", "authorId": "caregiver-1", "at": 1789564100000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789564101000, "text": "Nina was fussy before her nap" } },
{ "event": { "_tag": "SubmitRequested", "at": 1789564102000, "submissionId": "sub-recover-006" } },
{ "event": { "_tag": "SubmitRejected", "at": 1789564103000, "attempt": 0, "reason": "network" } },
{ "event": { "_tag": "RetryRequested", "at": 1789564134000 } },
{ "event": { "_tag": "ExtractionResultArrived", "at": 1789564135000, "attempt": 0, "outcome": "structured" } },
{ "event": { "_tag": "SubmitAccepted", "at": 1789564136000 } },
{ "event": { "_tag": "ExtractionResultArrived", "at": 1789564140000, "attempt": 1, "outcome": "structured" } }
],
"expect": {
"phase": "saved",
"tone": "success",
"bannerVisible": false,
"staleResultsSuppressed": 1,
"attempt": 1,
"extractionStatus": "structured"
}
}
21 changes: 21 additions & 0 deletions packages/capture-recovery/fixtures/07-discard-receipt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"id": "discard-receipt",
"kind": "discard-receipt",
"description": "The caregiver explicitly discards a sensitive capture. The raw text is cleared; a receipt (time, length, attempt, submissionId) remains so a discarded capture is a visible receipt — never silent absence — and the missing-log vs zero-care distinction is preserved. Post-discard edits are anomalies, not resurrections.",
"capture": { "captureId": "cap-recover-007", "authorId": "caregiver-1", "at": 1789564200000 },
"steps": [
{ "event": { "_tag": "TranscriptChanged", "at": 1789564201000, "text": "Ava bit her friend at pickup, we talked about it" } },
{ "event": { "_tag": "DiscardRequested", "at": 1789564202000 } },
{ "reload": true },
{ "event": { "_tag": "TranscriptChanged", "at": 1789564260000, "text": "attempted resurrection" } }
],
"expect": {
"phase": "discarded",
"tone": "neutral",
"title": "Discarded — nothing was added to the journal",
"bannerVisible": true,
"hasReceipt": true,
"rawCleared": true,
"anomalies": 1
}
}
26 changes: 26 additions & 0 deletions packages/capture-recovery/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@journal/capture-recovery",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Interrupted-capture recovery: pure state machine, truthful saved/pending/failed contract, replayable fixtures",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.test.json --noEmit",
"test": "bun test"
},
"dependencies": {
"@journal/domain": "workspace:*",
"effect": "4.0.0-rc.115"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "~6.0.3"
}
}
Loading
Loading