Skip to content

feat(capture-recovery): interrupted-capture recovery machine + truthful one-handed UI projection - #15

Merged
obvious-autobuild[bot] merged 1 commit into
masterfrom
spike/capture-recovery-ux
Sep 17, 2026
Merged

obvious-autobuild[bot] merged 1 commit into
masterfrom
spike/capture-recovery-ux

Conversation

@obvious-autobuild

Copy link
Copy Markdown
Contributor

Acceptance criteria

  1. Pure recovery machine with truthful phases: drafting / pending / saved / failed / discarded; saved is reachable only via SubmitAccepted (no optimistic truth) — pinned by tests.
  2. Interruption + backgrounding/process death: the persisted raw transcript alone is sufficient to resume; reload restores the draft verbatim with author attribution — fixtures 01, 02.
  3. Network loss while pending: the client never fabricates a failure or a save; the ack (if the server completed) lands on restore — fixture 03.
  4. Explicit failure → one-handed retry reuses the same submissionId (idempotent server replay) and bumps the extraction attempt — fixture 04.
  5. Duplicate submissions are suppressed while pending AND after save; duplicate server acks are idempotent no-ops — fixture 05.
  6. Stale extraction results (superseded attempt) are counted and discarded, never merged (contract v0.2 rule b) — fixture 06.
  7. Explicit discard leaves a receipt (time, length, attempt, submissionId), clears raw text, survives reload, resists post-discard resurrection — fixture 07.
  8. Additive only: imports CaptureId / ExtractionAttempt / ExtractionStatus from @journal/domain; zero changes to domain, entry, event, extraction, ui, security, or evaluation packages.
  9. Full-repo verification green on the pushed HEAD (pnpm turbo run typecheck test build).

What this is

Additive @journal/capture-recovery package: a bounded direct-worker extension of the canonical contract (art_I2TCG08V v0.2), positioned as a proposal implemented as code toward contract v0.3.

  • src/state.ts — recovery state schema (reuses canonical CaptureId, ExtractionAttempt, ExtractionStatus)
  • src/events.ts — 11 tagged recovery events (interruption, resume, submit/retry/accept/reject, extraction result, network, discard)
  • src/reducer.ts — pure, total reducer; unexpected combinations become bounded anomalies instead of throws
  • src/banner.ts — truthful one-handed UI projection: bottom-anchored, 48pt targets, ≤2 actions, copy derived from persisted state only
  • src/storage.ts — CaptureStorage contract (load / listUnresolved / listReceipts) with fail-closed decodeStoredState
  • src/replay.ts + fixtures/*.json — 7 replayable scenarios with cold-start reload steps
  • test/recovery.test.ts — 20 tests / 107 assertions pinning every invariant above
  • README.md — the contract document, including ownership/handoff (slot 22 offline-capture lane, voice/photo capture slice, review-UX candidates) and honest known limitations

Phases describe delivery of a capture, never audience: Entry.visibility (draft|published) stays the only per-entry publication state and household grants stay the audience dimension (contract v0.2, two independent dimensions).

Evidence

  • pnpm exec turbo run build typecheck test --filter=@journal/capture-recovery → 4/4 tasks green, 20 tests pass (HEAD ffa545d)
  • Full-repo pnpm typecheck && pnpm test && pnpm build → green (turbo cache, HEAD ffa545d)
  • CI: this PR

Evidence receipt (completed at merge)

PR:               <this PR>
Tested head SHA:  ffa545dc70a62f4492dba748f3a18a5d403f8446
Review result:    <pending>
Checks:           <pending — CI on ffa545d>
Merge commit:     <pending>
Post-merge smoke: <pending — turbo typecheck/test/build on merge commit>
Unlocked tasks:   slot 22 (todo_N1ukao5q) capture-side recovery kernel; review-UX candidates slots 11–13; contract v0.3 fold proposal

…ul one-handed UI projection

Additive @journal/capture-recovery package (proposal toward contract v0.3,
bounded extension of art_I2TCG08V v0.2):

- Pure recovery reducer: drafting/pending/saved/failed/discarded; saved only
  via SubmitAccepted (no optimistic truth); stable submissionId; monotonic
  attempt; stale-result suppression; duplicate-submit suppression; discard
  receipts; total reducer with bounded anomalies instead of throws.
- Truthful one-handed banner projection (bannerFor): bottom-anchored, 48pt
  targets, <=2 actions, copy derived from persisted state only.
- CaptureStorage contract (load/listUnresolved/listReceipts) with fail-closed
  decodeStoredState.
- 7 replayable JSON fixtures: call interruption, backgrounding+process death,
  network loss while pending, failed->retry, duplicate submission, stale
  result, discard receipt. 20 bun tests, 107 assertions.
- Imports CaptureId/ExtractionAttempt/ExtractionStatus from @journal/domain;
  no changes to domain, entry, event, extraction, ui, security, evaluation.
@obvious-autobuild

Copy link
Copy Markdown
Contributor Author

Independent review — verdict: pass-with-notes

Tested head SHA: ffa545dc70a62f4492dba748f3a18a5d403f8446 (clean detached checkout, verified via git rev-parse HEAD). Reviewer: independent review thread, 2026-09-17. Per the workflow, I do not merge — merge ownership rests with the serialized sweep owner.

What I ran at the exact HEAD

  • corepack pnpm install --frozen-lockfile → lockfile up to date, 505 packages, clean
  • pnpm exec turbo run typecheck test build (full repo) → 12/12 tasks successful, 0 cached, 5.0s
  • Focused capture-recovery suite (within the turbo run) → 20 pass / 0 fail / 107 expect() calls — matches the PR body's claim exactly
  • CI Typecheck, test, build → SUCCESS on ffa545d (run 35258877099; head_sha verified via API, not inferred from the PR page)
  • Full diff read against the stated acceptance criteria

Scope (criterion 8) — verified additive-only

20 files, 1367 insertions, 0 deletions: packages/capture-recovery/** + pnpm-lock.yaml (importer block for the new package only). Zero changes to domain, entry, event, extraction, ui, security, or evaluation packages. Imports CaptureId / ExtractionAttempt / ExtractionStatus from @journal/domain and modifies nothing in the domain.

Truthfulness invariants — verified in source, not just test names

  1. No optimistic truth — reducer.ts writes phase: "saved" in exactly one place: the SubmitAccepted case (pending → saved; a repeat accept while saved returns the same state object — referential identity is pinned by expect(again).toBe(state)). SubmitRequested only reaches pending; NetworkLost/NetworkRestored never change phase (fixture 03's timeline shows pending at both steps, then saved only when the ack lands).
  2. Persisted raw transcript alone suffices to resume — the replay reload step hydrates ONLY from storage.load; fixtures 01/02 restore verbatim raw text + authorId after cold start. decodeStoredState is fail-closed (Schema.decodeUnknownSync — a corrupt record cannot enter the machine).
  3. Idempotent retry — RetryRequested (reachable only from failed) patches phase/attempt/extractionStatus and leaves submissionId untouched from the first submit; fixture 04 asserts the same id with the attempt bumped.
  4. Stale results counted, never merged — ExtractionResultArrived/SubmitRejected with attempt < state.attempt bump the suppressed counter only; a future attempt is an anomaly. Fixture 06.
  5. Duplicate suppression while pending AND after save — same-id re-submit while pending or saved → suppressed; foreign id → anomaly, never a takeover (fixture 05: two suppressed, one saved).
  6. Discard receipt + resurrection resistance — DiscardRequested (only from drafting/failed) records {at, transcriptLength, attempt, submissionId?}, clears the raw text, and from discarded no event returns the machine to a live phase — every post-discard event becomes a bounded anomaly. The receipt survives reload, and listReceipts keeps discarded captures separate from listUnresolved (missing-log vs zero-care).

Consumer lens (candidate B's two hard-requirement failures): interruption recovery is closed by fixtures 01–03; the truthful failed-extraction state is closed by failed being reachable only via an explicit SubmitRejected with a reason (banner copy names server vs network problem) plus stale-result suppression, so a wrong/superseded extraction can never overwrite a current one.

Notes (non-blocking)

  1. No verification-manifest:v1 block. Zero occurrences in the PR body, the comments, or the diff. This PR opened before the verification gate (PR feat(verification): executable merge gate — PR evidence manifests, validator with negative controls, merge sweep #26) existed, which is expected — the merge owner must record the manifest on the tested head (ffa545d) before sweep can run. I am not adding it.
  2. The one-handed UI is a projection contract, not rendered UI. banner.ts exports truthful RecoveryBanner data (≤2 actions, touchTargetPt: 48, anchored: "bottom" enforced by schema literals); no RN component renders it in this package. The consuming parent-home prototype must honor those fields in its own components.
  3. Only InMemoryCaptureStorage ships. The durable AsyncStorage/SQLite adapter is deferred to slot 22 integration, and the recovery guarantee depends on the caller persisting after every transition (save-on-transition). That contract is documented in storage.ts but not type-enforced — a batch-saving durable adapter could reopen the exact loss window this machine closes. Worth an integration test at the consumer.
  4. The evidence receipt in the PR body still carries <pending> fields — expected pre-merge; fill them at merge per the workflow.

Verdict for the serialized sweep: review result pass-with-notes on ffa545dc70a62f4492dba748f3a18a5d403f8446; checks green on the same SHA; manifest registration is the remaining pre-merge step for the merge owner.

@obvious-autobuild

Copy link
Copy Markdown
Contributor Author

Merge-owner suite evidence — tested head ffa545dc70a62f4492dba748f3a18a5d403f8446

Run by the serialized merge owner (parent-home recovery lane) in a clean detached worktree at the tested head, 2026-09-17:

  • bun test ./security → 17 pass / 0 fail (22 expect() calls)
  • cd evaluation && bun src/run.ts → 6/6 fixtures PASS (incl. malformed-extraction failure path with raw preserved, retry-double-submit exactly-once, raw-fidelity sha256, reload-persistence cold-start)
  • bun src/run.ts --adapter=./src/example/broken-adapter.ts --expect-failure → failed as expected (faults caught: cap-reload-001/002 after-reload) — negative control satisfied
  • Context: this PR predates the verification gate (PR feat(verification): executable merge gate — PR evidence manifests, validator with negative controls, merge sweep #26); suites recorded by the merge owner ahead of the sweep, per the reviewer's note.

@obvious-autobuild

Copy link
Copy Markdown
Contributor Author
{
  "manifestVersion": 1,
  "pr": 15,
  "prUrl": "https://github.com/OCPdev25/obv-hackaton/pull/15",
  "testedHeadSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
  "classification": "backend-only",
  "classificationJustification": "Pure TypeScript package under packages/capture-recovery plus lockfile only (no apps/, backend/, or other package paths changed); the one-handed UI ships as a projection contract, not rendered RN",
  "review": {
    "result": "pass-with-notes",
    "reviewer": "independent review agent — parent-home recovery lane (th_V9xo0wZ7)",
    "reviewedHeadSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
    "date": "2026-09-17",
    "url": "https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720572634"
  },
  "checks": [
    {
      "name": "Typecheck, test, build",
      "status": "green",
      "headSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
      "url": "https://github.com/OCPdev25/obv-hackaton/actions/runs/35258877099/job/105329282536"
    }
  ],
  "evidence": [
    {
      "kind": "test-run",
      "name": "capture-recovery focused suite: 20 pass / 0 fail / 107 expect() calls",
      "headSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
      "result": "pass",
      "url": "https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720572634"
    },
    {
      "kind": "suite-run",
      "name": "turbo typecheck/test/build full repo: 12/12 tasks, 0 cached",
      "headSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
      "result": "pass",
      "url": "https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720572634"
    }
  ],
  "suites": {
    "security": {
      "ran": true,
      "result": "pass",
      "headSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
      "url": "https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720601545"
    },
    "evaluation": {
      "ran": true,
      "result": "pass",
      "negativeControl": "fail-as-expected",
      "headSha": "ffa545dc70a62f4492dba748f3a18a5d403f8446",
      "url": "https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720601545"
    }
  },
  "notes": "Security and evaluation suites run by the serialized merge owner in a clean detached worktree at the tested head; run log in https://github.com/OCPdev25/obv-hackaton/pull/15#issuecomment-5720601545. Manifest recorded by merge owner because the PR predates the verification gate (PR #26). Reviewer consumer notes (projection 48pt/bottom-anchored rendering, durable-adapter integration test at consumer) routed to the composite consumer PR."
}

@obvious-autobuild
obvious-autobuild Bot merged commit 9ea7e73 into master Sep 17, 2026
1 check passed
obvious-autobuild Bot added a commit that referenced this pull request Sep 17, 2026
…domain (#35)

Post-squash adaptation for PR #15's merge commit 9ea7e73: the capture-recovery
package was written against a pre-v0.3-fold domain where ExtractionAttempt was
a numeric attempt counter and CaptureId was an unbranded string. The canonical
domain renamed the counter to AttemptNumber (adaptation A5) and now brands
CaptureId (Schema.NonEmptyString.pipe(Schema.brand)).

- state.ts / events.ts: attempt fields typed AttemptNumber (the counter)
- reducer.ts: createCapture input takes the branded CaptureId (type-only
  import; the pure reducer gains no runtime dependency)
- replay.ts: decode the fixture captureId through the canonical CaptureId
  schema at the boundary (fail-closed, no casts)
- test: decode branded CaptureIds at the four direct createCapture sites and
  in the storage listUnresolved/listReceipts expectations
- README: correct the imported-type names

No behavioral change to the recovery machine's truthfulness invariants; the
six fixture scenarios, their expectations, and the discard-receipt semantics
are unchanged.

Co-authored-by: Obvious <obvious@obvious.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants