Skip to content

Chronological narrative of how the build actually went. Different from CHANGELOG.md (terse, per-version) and decisions/ (one decision per file). This is the place for the story the other artifacts can’t carry: what we tried, what surprised us, what the data taught us, what we’d do differently.

Entries are voluntary and lossy by design. Skip a session, skip a week — write when there’s something worth writing.

Format

Each entry is a level-2 heading with the date and a short title. Underneath, freely-formatted prose. Keep it scannable; bullets and short paragraphs both fine.

## 2026-05-03 · Stood up V0 + V0.1 + V0.2 web slice in one session
The initial plan was V0 only…

If an entry surfaces a decision worth preserving, link to / spawn an ADR.


2026-05-07 · Topic graph end-to-end shipped; production-prep stragglers cleared

PR #11 (feature/topic-graph) merged this morning — 19 commits, ~3000 LOC, the entire ADR-0012 V0.3 slice plus the β.2 navigation surfaces. Every typed topic now has a permanent home: extracted during summarize, resolved into a canonical entity row, joined to its memories, surfaced as inline highlights on the transcript and chips on Today rows, and reachable via /topic/[id], the /topics index, and a topic:Foo search operator. Both web and mobile, edge filters in the Universe.

What was harder than expected: the verify_jwt=false config gap on link-topics-step. Symptoms looked like resolve_topic was failing — DLQ entries piled up, topics table stayed at 0 rows, direct invocation of the function from a script returned a generic {"ok":true,"resolved_count":3} once the issue was found. The diagnostic that cracked it (scripts/debug-link-topics.ts) is now permanent alongside debug-tick.ts. Lesson: when adding a new Edge Function on a project using sb_secret_* keys, config.toml verify_jwt entry is non-optional — the gateway 401’s before the function’s own auth check ever runs, so you don’t see a 401 in function logs.

What was easier than expected: the data layer. The schema shape (topics + memory_topics + transitional topics_meta) held up across every downstream consumer — search, viewer chips, inline highlights, /topic, /topics, Universe edge filter — without needing an iteration. The hybrid pg_trgm + pgvector resolution in the SQL function feels overkill on the small dev dataset but will earn its keep at user scale (Daniel/daniel/Dan collapse via trigram, Dr. Patel/my doctor via cosine).

What’s now visibly stale: the README “Universe — Rough” line. Updated this turn to reflect topic graph + Topics index + edge-kind filter. Stakeholder-facing doc now matches what’s deployed.

Production prep cleanup landed alongside: 30-day prune cron on pipeline_dead_letters (daily 03:00 UTC), one-time migration flipping null topics_meta to [] for empty-transcript memories that the backfill candidate filter would otherwise skip forever. Skipped: removing the dev-skip login path. The Skip button is the current dev workflow per a project memory; pulling it gates on release prep + Apple Developer setup, which is itself deferred.

Next slice: γ remainder — the visual force-graph on mobile. Web shows the Universe; mobile gets /topic, /topics, chips and search but no graph yet. react-native-skia + d3-force per the phase plan; builds against topic-as-node from day one. After that, MCP topic surface (memories_by_topic, related_topics) is the high-leverage half-day item that wires the chat agent into the graph instead of just embed-search.


2026-05-06 · E2E pipeline test green; topic-graph strategy session → ADR-0012

Two threads landed the same day.

E2E pipeline test passed on deployed Supabase. The full chain — record → transcribe → diarize → reid → summarize → embed → edges — ran end-to-end against the hosted project. This was the cheapest pre-demo drift catch on the 03_PROGRESS.md “Next up” list and it’s now off the queue. Branch: feature/e2e-pipeline-test. Going forward, any pipeline schema change (e.g. the topic-graph slice below) gets re-validated against the same path before merge.

Strategy session on graph view + tagging surfaced a real gap. Started as a question about “do we copy Obsidian’s [[wikilinks]] + frontmatter pattern, or borrow from Tana / Heptabase / mind-maps?” Walked through all four families:

  • Link-driven graphs (Obsidian, Logseq, Roam) — graph is a byproduct of writing wikilinks. Wrong shape for ARCIVE: the input is voice memos / photos / shared URLs, not editable markdown.
  • Typed-node systems (Tana, Capacities, Notion) — supertags are schemas, not labels. Right shape for AI-authored knowledge.
  • Spatial canvases (Miro, Heptabase, Scrintal, Excalidraw) — manual arrangement; weak fit for ambient capture.
  • Mind maps (XMind, Coggle) — hierarchical trees; too rigid for evolving knowledge.

The framing that landed: ARCIVE is structurally Tana with a force-graph skin — typed entity nodes, AI-populated, queryable, with the graph as one of several surfaces. Not Obsidian; we never have prose to sprinkle wikilinks into.

Then the surprise. Looked at the existing pipeline: supabase/functions/summarize-step/index.ts:24-51 already extracts topics from transcript text with the explicit prompt framing “topics are GRAPH EDGES, not search facets” — biased toward recurring nodes (people, projects, themes), 2–4 typical, 5 max. The work has been happening since the ADR-0011 summarize-cascade landed.

The gap: every extracted topic is written to memories.topics text[] and never used as a graph primitive. compute-edges-step builds memory↔memory edges purely from embedding similarity (top-8, ≥0.55 cosine). Two memories that both tag Daniel are only connected if their vectors happen to land near each other. Plus there’s no canonicalization — Daniel / daniel / Dan accumulate as separate labels.

Outcome: drafted ADR-0012 (Proposed). Promote topics to first-class entity nodes via a small topics + memory_topics schema, hybrid pg_trgm + pgvector resolution in a new link-topics-step, topic-shared edges in compute-edges-step alongside the existing semantic ones, and inline highlights in the transcript viewer (proper-noun kinds = links, theme kinds = chips). Universe view nodes become topics by default; memory↔memory edges become a secondary toggle.

Lesson worth recording: before proposing new infrastructure, read the prompts. The chat conversation at the start of the session was heading toward “we should add topic extraction” — a half-day discovery in the existing code revealed we’d been doing it for weeks and just stranding the output. The actual ADR is “use what we already extract,” not “extract more.”

Ordering implication for “Next up”: mobile Universe view (originally queue position #2) was about to render the same fuzzy memory↔memory graph the web has today. If ADR-0012 is accepted, that work should be deferred behind the topic-graph slice so it builds on the right node source from day one. Re-ordering captured in 03_PROGRESS.md “Next up” as a phased rollout (α/β/γ/δ/ε).

Companion working notes archived at discussions/2026-05-06_graph_tagging_strategy.md.


2026-05-05 · Migrated to Supabase’s new API key format

Supabase is moving from JWT-format anon / service_role keys to non-JWT sb_publishable_* / sb_secret_* keys (discussion #29260, new keys GA in 2025, legacy deletion late 2026). The trigger to act now: ARCIVE’s project disabled the legacy keys. Old auth pattern broke immediately — the 44-memory summarize backfill (see same-day ADR-0011 work) sat in pgmq.pipeline_jobs because pipeline-tick dispatched to step functions with Authorization: Bearer <jwt> only, and Supabase’s gateway rejects the new sb_secret_* format in Authorization without a matching apikey header.

Fix shipped on docs/ai-strategy-architecture branch:

  • supabase/functions/pipeline-tick/index.ts and supabase/functions/ingest-audio/index.ts now send both apikey and Authorization headers (same value) on outbound Edge Function calls. Backward-compatible with legacy JWT keys; works with new sb_secret_* keys via Supabase’s documented backward-compat exemption.
  • Inbound auth checks in step functions unchanged — the Authorization === Bearer ${SERVICE_ROLE_KEY} check still passes when both headers ride together.
  • .env.example annotated to indicate either key format works in the existing variable slots.
  • docs/SHARING.md curl examples updated to send both headers.

Mistake worth recording: I (Claude) initially leaned on PowerShell

  • HTTP triggering of backfill-summaries to run the backfill. That sent us through ~6 turns of variable-state, JWT-format, and 401 debugging because PS quoting + JWT pasting + Supabase’s two auth boundaries (gateway + function) compose poorly. The actually-right move was to bypass HTTP entirely and run the enqueue as raw SQL in the dashboard — one query, no auth, no quoting, instant feedback. Lesson: for one-shot DB-shaped work, SQL Editor first, HTTP function second. The Edge Function still earns its keep for cron-driven incremental backfill, but it’s the wrong primitive for manual triggering.

What we did NOT do (yet): write an ADR formalizing the new-keys migration. The change is small enough and the upstream Supabase discussion authoritative enough that an ADR feels like ceremony. Trigger to write it: a future migration breaking this assumption, or a B2B SOC 2 review asking how key rotation is handled. (Number-wise: ADR-0012 was subsequently claimed by the topics-as-graph-nodes proposal on 2026-05-06; a future new-keys ADR will be 0013+.)

2026-05-05 · Voice talk-back paused — ADR-0010

Spent an hour scoping “next up” and ended up parking the V0.2 voice storyline instead of advancing it. Three things converged.

The mobile spike came back negative. Pipecat’s JS client depends on navigator.mediaDevices.getUserMedia and Web Audio APIs that don’t exist in React Native. The fix is react-native-webrtc, which is a native module — Expo Go can’t load it, and we’re holding EAS / Apple Developer for release prep. So mobile voice is structurally blocked. Cross-platform parity (per the project memory) means don’t ship single-platform unless explicitly confirmed. This case fails that bar.

MCP wiring on the voice service has a latency problem. Pure tool-use means STT → Haiku (asks tool) → MCP search → Haiku (final) → Cartesia. Two Haiku hops put retrieval-bearing turns at ~2-2.5s, above the 1.5s budget in 01_SOFTWARE_PLAN §1.8. A hybrid (session- open pre-fetch + tool-use for explicit recall) would work but is real work for a feature with no users.

The architecture envelope shifted under us. 2025 saw speech-to- speech models reach GA — OpenAI Realtime, Gemini Live, AWS Nova Sonic — with native MCP tool support. Microsoft’s VibeVoice (MIT-licensed, open-source long-form TTS) and Kyutai’s Moshi (full-duplex speech-to-speech, open) opened the OSS side too. Pipecat’s STT→LLM→TTS shape (chosen in ADR-0002) is no longer obviously the right answer — but Anthropic still has no native real-time speech model, so staying on Claude means staying on Pipecat-shaped composition. That tension isn’t urgent to resolve, but it makes investing in Pipecat polish now feel premature.

So: pause. The service stays runnable behind a “paused” notice on /talkback; the Voice nav link is gone; PROGRESS, README, About, and the worker README all point at ADR-0010. Code is untouched — resumption is a single revert PR.

What we do instead: real e2e pipeline test on deployed Supabase (cheap, high-info), Universe + role conversations on mobile (visible parity gap users actually see). Voice comes back when EAS unlocks AND a concrete user signal tells us whether to push hybrid- on-Pipecat or supersede ADR-0002 with a Realtime/VibeVoice/Moshi swap.

2026-05-04 · V0.2 mobile slice — capture, playback, transcode

A long session that took the Expo app from “scaffolded shell” to “actually usable on a real iPhone” and resolved the cross-platform audio playback story.

Where we ended up

feature/mobile-expo-followups is 6+ commits ahead of master:

  • Expo SDK 54 scaffold (auth, Today, Memory detail, recorder).
  • Hosted-Supabase-only dev workflow (dropped Path A / Docker).
  • Audio playback on Today rows + Memory detail with drag-to-seek.
  • Offline upload queue (SQLite) with auto-flush on app foreground.
  • Date-bucketed grouped feed, search via ILIKE.
  • Per-recording resume position + share-to-space + delete + duration metadata + pipeline error/processing state on rows.
  • Audio transcode worker live on Modal, ingest-audio dispatching it, cron sweeper as backstop.
  • Voice service: per-role system prompt lookup added.

The audio rabbit hole

This was the headline pain. Tested on a real iPhone, multiple distinct bugs surfaced in sequence and each looked like the previous one:

  1. Two simultaneous useAudioPlayer instances (one per row) hung on iOS — second player never reached isLoaded: true. Fix: a single AudioContext with one player, swap source via state.
  2. Queue-flushed files arrived corrupt. Direct uploads worked, queue uploads didn’t. RN’s fetch(url, { body: blob }) mangles binary on iOS (issue #27099). Switched the queue to expo-file-system uploadAsync which streams the URI directly. No more bad bytes.
  3. Bare audio/m4a MIME is rejected by AVPlayer — only audio/x-m4a and audio/mp4 work. Apple quirk. ingest-audio now normalises to audio/mp4 server-side.
  4. iOS audio session stuck in record-only after recording — flip back to playback in setAudioModeAsync on stop, plus default to playback at app launch.
  5. End-of-track tap was a no-op (play() after currentTime == duration does nothing) — rewind to 0 first when at end.
  6. Web-recorded files (audio/webm; codecs=opus) don’t play on iOS at all — AVPlayer has no Opus decoder. This was the one that justified the transcode worker.

Transcode worker — why and how

webm/Opus is Chrome’s MediaRecorder default. iOS will never play it. We can’t reliably normalise client-side (ffmpeg-wasm is huge, slow on phones). Server-side transcode is the right answer.

Built backend/workers/audio-transcode/main.py on Modal: webhook endpoint that ingest-audio fires per upload, plus a 10-minute cron that sweeps any rows where playback_storage_path is null. Schema gained playback_storage_path + playback_content_type (nullable); both apps prefer the playback path when set, falling back to the original.

First version was too clever — branched on the input MIME via a storage.objects schema query that returned empty data despite the service-role key. Modal cron ran 9 times “Succeeded” with zero rows processed, no logs. Diagnosis took longer than it should have. Fix: drop the MIME branching entirely; always pipe through ffmpeg → AAC. ffmpeg passes through cheaply if input is already AAC, definitively converts everything else. ~1 second real-time per 30s clip.

Added print() statements liberally + a @local_entrypoint so modal run echoes return JSON instead of swallowing it. Future debugging will be cheaper.

Backfill on first deploy: 7 of 8 existing recordings transcoded cleanly; 1 was the airplane-mode test file whose Storage object had been deleted earlier in the session — flagged with playback_storage_path = "" so the cron stops retrying.

Things that bit and how to avoid them

  • pnpm + Expo native module resolution. @expo/metro-runtime imports whatwg-fetch without declaring it; pnpm’s strict layout hides it. Fix: add whatwg-fetch as a direct dep in apps/mobile/package.json. The “right” pnpm answer is hoisted layout via root .npmrc, but the targeted dep was simpler for now.
  • Expo Go SDK pinning. App Store Expo Go forces the latest SDK. Pinning to SDK 53 hit “incompatible with Expo Go 54.0.0” the moment Expo cut a release. Bumped to 54 + ran expo install --fix to realign all related deps in one shot.
  • iOS audio file extensions vs. content-types. AVPlayer on iOS uses Content-Type from the HTTP response, not the URL extension. But it’s MIME-string-strict — audio/m4a is rejected even though audio/x-m4a and audio/mp4 decode the same file. Always store audio/mp4.
  • CDN cacheControl on Storage objects. Even after re-uploading with a corrected MIME, the previous response stayed cached for an hour. Fix: always set cacheControl: "0" on Storage uploads when re-correcting; or wait an hour.
  • Modal modal run swallowing return values. Add an @app.local_entrypoint() wrapper that prints, or scatter print() calls inside the function. Empty terminal output ≠ function did nothing.

What I’d do differently

  • Default to ffmpeg “always-transcode” from the start; skip the MIME-detection cleverness. Server is fast, the cost is trivial, and the code is easier to reason about.
  • Sketch the audio-context (single shared player) on day one rather than per-row players. Caught only after the second-row-stuck logs pointed at session contention.
  • Push to origin more often. The branch is now 6+ commits ahead with no upstream backup. If the laptop dies before push, that’s hours of work gone.

Open thread for next session

  • Push feature/mobile-expo-followups to origin and open a PR.
  • Resend SMTP setup so magic-link emails actually go out under even modest user load (Supabase Free hits 4-emails/hour fast).
  • Voice talk-back client on mobile — Pipecat WS transport already has an RN-compatible client; mostly UI port from the web’s voice-client.tsx.
  • Apple Developer account decision. Background capture is the V0.2 differentiator and needs an EAS development build, which needs the $99/yr account.

2026-05-03 · Session handoff → production hardening next

End of a long session. Next session starts on production hardening from a fresh chat and a fresh feature branch. This entry is the handoff so whoever picks up doesn’t have to scroll a multi-hour transcript.

Where this branch is right now

  • claude/review-docs-start-app-sZD2f carries everything from V0 through V0.3 slice 1. Tagged locally as v0.0.1, v0.1.0, v0.2.0-rc.1 (tags need pushing from a normal git env — sandbox blocks tag push with 403).
  • PR #1 is open against master. Big PR but each commit is a coherent slice; commit log reads as the chronological build.
  • pnpm -r typecheck and pnpm --filter web build are both clean on the latest commit.

What’s actually next (production hardening)

Pre-launch list, ordered roughly by leverage. None require a deployed environment to scaffold; some require a real Supabase to validate.

  1. pgmq DLQ + max-retry policy. Today messages retry forever via visibility timeout. Add a max-retry counter and dead-letter table; surface stuck jobs somewhere readable. Worth an ADR.
  2. Rate limits on Stripe + ingest routes. /api/chat is rate- limited; Stripe and ingest aren’t. Same consume_* RPC pattern.
  3. Sentry explicit captures on critical paths. We init Sentry but don’t capture anything beyond Next defaults. Wrap Edge Function error paths and the chat/voice driver error events.
  4. Production prep for dev_pass. Migration to flip default to false and bulk-update existing rows. Wrap <DevPassSection /> in process.env.NODE_ENV !== "production". Replace the vault.create_secret('replace-with-real-…') placeholder.
  5. Add backend/mcp/arcive-memory-mcp/ to CI typecheck. It’s standalone (not in pnpm workspace); CI currently skips it.
  6. Universe pagination. Caps at 500 memories + 2000 edges in one query — won’t scale past a few hundred recordings.
  7. Real end-to-end pipeline test. Run a chunk through transcribe → diarize → reid → summarize → embed → edges on a deployed Supabase. Highest-leverage validation pending.
  8. PWA install smoke test on real iOS / Android devices.

Gotchas the next session should know

  • Branch policy: the previous sessions were authorized for claude/review-docs-start-app-sZD2f. Next session needs explicit permission for a new feature branch. Suggest something like feature/production-hardening off master (after merge) or off this branch’s tip.
  • Tag pushes 403 on the sandbox’s git proxy. Local tags exist; push from a real env via git push origin v0.0.1 v0.1.0 v0.2.0-rc.1.
  • dev_pass defaults to true for all users. The migration comment says to flip the default before opening signups; this is a hardening item.
  • MCP server isn’t in CI. Adding it requires either putting it in the pnpm workspace or adding a separate CI step that installs its deps independently. The latter is probably cleaner since the MCP server has its own deploy lifecycle.
  • The vault.create_secret('replace-with-real-…') placeholder needs replacing in any environment where pg_cron should actually fire. Defensive DO ... EXCEPTION blocks mean a missing/placeholder value doesn’t break the migration suite, but cron also doesn’t fire — pipeline runs via direct-HTTP fallback only.
  • PR #1 contains tons of work. Splitting into multiple PRs retroactively is doable (one per logical slice — see commit log) but costly. Easier to keep this PR as the foundation and ship hardening as a separate PR.

Suggested commands for the next chat

The new chat opens by reading the docs scaffold:

1. Read docs/03_PROGRESS.md for current state.
2. Read docs/decisions/ to absorb why prior choices stand.
3. Read docs/04_JOURNAL.md most-recent-first for narrative
continuity (this entry is the orientation).
4. Branch off this branch's tip (or master, post-merge) for
feature/production-hardening.
5. Start with item 1 (pgmq DLQ). Think before coding — DLQ shape
needs an ADR.

What I would not do next session:

  • Don’t expand the PR #1 scope further. It’s at a natural max.
  • Don’t tackle mobile (Expo) or hardware in the same slice as hardening. They’re different shapes of effort.
  • Don’t replace the dev_pass mechanism mid-hardening. Just flip the default and hide the toggle. The mechanism is the right shape for v1 too.

Decisions captured this session

  • 0001 pgmq + pg_cron
  • 0002 Pipecat vs OpenAI Realtime
  • 0003 AgentDriver interface
  • 0004 MCP as separate service
  • 0005 dev_pass design
  • 0006 voice naturalness
  • 0007 consent gate

Decision worth opening fresh in the next session:

  • DLQ shape for pgmq — single dead-letter table per queue, or one global, or mark in-place? This is the first decision the next session should make and write up.

Cut /api/chat over to the MCP server we’d scaffolded earlier. The nicest part of this slice was how little had to change: the AgentDriver interface already takes searchMemories as a callback, so the route just had to pick which implementation to hand it. When MCP_SERVER_URL is set the call goes through the MCP client; when it isn’t, the inline path keeps working. Local dev keeps working without a deployed MCP server.

The consent gate was the more interesting design. The first instinct was to filter at the agent layer — make Caregiver refuse to use non-consented memories. But that’s prompt-only enforcement, and the LLM still sees the data in retrieval. The right cut is at the SQL RPC, opt-in via parameter. Every agent path passes respect_consent: true; user-facing search keeps the default false so the data owner can browse their own /today freely. Same gate, four roles, one place to enforce.

A subtlety I almost missed: re-ID auto-creates “Unknown speaker” people with consent_status='pending'. That means newly identified voices are gated out of agent retrieval until the owner explicitly grants consent — failure-closed by default. Which is the right default for this product.

Captured as ADR-0007.


2026-05-03 · From docs to V0.2-rc.1 in one session

Started from a docs-only repo: master plan, software plan, hardware plan, and the canonical product spec. By end of session, V0 + V0.1 + V0.2 web slice were on a branch and a PR was open against master.

Things that went smoothly:

  • The plans were precise enough that scope decisions were mostly just “follow the deliverable list”. Phase 0 / V0 closed out cleanly.
  • pgmq + pg_cron sounded scarier than it turned out to be. The defensive DO ... EXCEPTION migration (ADR-0001) was the right move — degrades gracefully when extensions are missing, never blocks the rest of the migration suite.
  • The AgentDriver interface paid off the moment we swapped from stateless RAG to the Claude Agent SDK driver. One import line changed in /api/chat. Worth more than the abstraction tax.

Things that bit:

  • First CI run failed in 8 seconds because pnpm/action-setup@v4’s version: 10 arg conflicted with the packageManager field in package.json. Removed the explicit version, bumped Node to 22, green next attempt.
  • The Pipecat JS client API I sketched first had serverUrl instead of wsUrl and an outdated callback signature. The cure was reading the .d.ts files in node_modules — faster than fetching docs. Lesson: when an SDK upgrade lands, read its types, don’t trust memory.
  • Tag pushes are 403’d by the sandbox git proxy. Tags are correct locally. Documented in the response so the user can push from a normal env.

Things deferred:

  • Mobile (Expo). The biggest V0.2 deliverable. Two reasons: the user was on a phone all session and couldn’t run mobile tooling; and the scope alone is multi-day even with focused work.
  • Real end-to-end pipeline test. We built the steps and the chain but never ran a chunk through them on a deployed Supabase. That’s the highest-leverage validation work pending.

Decisions captured as ADRs in this session:

  • 0001 — pgmq + pg_cron
  • 0002 — Pipecat composed pipeline
  • 0003AgentDriver interface
  • 0004 — MCP as a separate Node process
  • 0005dev_pass for tier-gate bypass
  • 0006 — Voice naturalness strategy

What I’d do differently if starting over:

  • Set up CI on commit 1, not commit 4. Earlier failure surfacing.
  • Settle the version/tag/changelog convention before the first feature ships, not after V0.2-rc.1 is out the door. The ADRs and PROGRESS doc both want that scaffold from day zero.
  • Wire MCP retrieval into /api/chat before shipping the standalone MCP server, so the inline Voyage call never lived in two places. Now we have a temporary duplication to undo.