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.tsandsupabase/functions/ingest-audio/index.tsnow send bothapikeyandAuthorizationheaders (same value) on outbound Edge Function calls. Backward-compatible with legacy JWT keys; works with newsb_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.exampleannotated to indicate either key format works in the existing variable slots.docs/SHARING.mdcurl examples updated to send both headers.
Mistake worth recording: I (Claude) initially leaned on PowerShell
- HTTP triggering of
backfill-summariesto 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:
- Two simultaneous
useAudioPlayerinstances (one per row) hung on iOS — second player never reachedisLoaded: true. Fix: a singleAudioContextwith one player, swap source via state. - 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 toexpo-file-system uploadAsyncwhich streams the URI directly. No more bad bytes. - Bare
audio/m4aMIME is rejected by AVPlayer — onlyaudio/x-m4aandaudio/mp4work. Apple quirk. ingest-audio now normalises toaudio/mp4server-side. - iOS audio session stuck in record-only after recording — flip
back to playback in
setAudioModeAsyncon stop, plus default to playback at app launch. - End-of-track tap was a no-op (
play()aftercurrentTime == durationdoes nothing) — rewind to 0 first when at end. - 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-runtimeimportswhatwg-fetchwithout declaring it; pnpm’s strict layout hides it. Fix: addwhatwg-fetchas a direct dep inapps/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 --fixto 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/m4ais rejected even thoughaudio/x-m4aandaudio/mp4decode the same file. Always storeaudio/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 runswallowing return values. Add an@app.local_entrypoint()wrapper that prints, or scatterprint()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
originmore 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-followupsto 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-sZD2fcarries everything from V0 through V0.3 slice 1. Tagged locally asv0.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 typecheckandpnpm --filter web buildare 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.
- 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.
- Rate limits on Stripe + ingest routes.
/api/chatis rate- limited; Stripe and ingest aren’t. Sameconsume_*RPC pattern. - 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.
- Production prep for
dev_pass. Migration to flip default tofalseand bulk-update existing rows. Wrap<DevPassSection />inprocess.env.NODE_ENV !== "production". Replace thevault.create_secret('replace-with-real-…')placeholder. - Add
backend/mcp/arcive-memory-mcp/to CI typecheck. It’s standalone (not in pnpm workspace); CI currently skips it. - Universe pagination. Caps at 500 memories + 2000 edges in one query — won’t scale past a few hundred recordings.
- Real end-to-end pipeline test. Run a chunk through transcribe → diarize → reid → summarize → embed → edges on a deployed Supabase. Highest-leverage validation pending.
- 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 likefeature/production-hardeningoffmaster(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_passdefaults totruefor 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. DefensiveDO ... EXCEPTIONblocks 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_passmechanism 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.
2026-05-03 · MCP wiring + consent gate (V0.3 slice 1)
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 ... EXCEPTIONmigration (ADR-0001) was the right move — degrades gracefully when extensions are missing, never blocks the rest of the migration suite. - The
AgentDriverinterface 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’sversion: 10arg conflicted with thepackageManagerfield inpackage.json. Removed the explicit version, bumped Node to 22, green next attempt. - The Pipecat JS client API I sketched first had
serverUrlinstead ofwsUrland an outdated callback signature. The cure was reading the.d.tsfiles innode_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
- 0003 —
AgentDriverinterface - 0004 — MCP as a separate Node process
- 0005 —
dev_passfor 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/chatbefore shipping the standalone MCP server, so the inline Voyage call never lived in two places. Now we have a temporary duplication to undo.