Infrastructure Components

PostgreSQL

Required

Serves two distinct roles:

  • Application data — recordings, users, AI sessions, messages, skills (Doctrine ORM)
  • Message queue — transcription jobs are stored durably in a database table (the async Doctrine transport). PostgreSQL LISTEN/NOTIFY then wakes the worker the moment a job is enqueued instead of waiting on the poll interval — but the notifications aren't persistent, so the worker still polls the table as a fallback, which is what keeps a job enqueued while it's down (e.g. mid-deploy) from being lost.
Alternative: RabbitMQ can replace the message queue role. Symfony Messenger supports AMQP natively. Adds infrastructure cost but handles high message volumes better.

Mercure

Required

Server-Sent Events hub used to stream live updates from PHP to the browser:

  • Transcription status — worker publishes progress updates, browser subscribes via EventSource
  • Rewriting chat — each user message and assistant token delta is published to a per-session topic. Every open tab on the same recording renders the same stream simultaneously.

Routed same-origin at /.well-known/mercure to avoid CORS. The browser gets a JWT cookie via Authorization::setCookie to authorize subscriptions.

Alternative: WebSockets (more complex, bidirectional not needed). The HTTP request handling the chat keeps the connection alive while it streams to Mercure, with ignore_user_abort(true) ensuring the assistant message persists even if the browser closes mid-stream.

Vault KMS

Required (prod)

Encrypts and decrypts per-user AI provider API keys at rest. The api-keys endpoint provides transit encryption — the key material never leaves the vault.

Alternative (dev): APP_SECRET-derived encryption via sodium. Less secure since the key lives in the environment, but works without external services.

Network Storage

Required

Shared mount at /storage/audio for uploaded audio files. Both the web container (receives uploads) and the transcription worker (sends audio to Whisper) need access to the same files. Without shared storage, the worker can't read what the web container wrote.

Usage stays flat rather than growing: the worker deletes each file once its transcript is saved. Only unresolved recordings — transcription failed, or returned an empty transcript — keep their audio, so it can be retried or downloaded.

OpenSearch

Optional

Full-text search alternative to the database's built-in FTS. Controlled by the SEARCH_PROVIDER environment variable — by default (any value other than opensearch) it uses the database's own FTS, auto-detecting PostgreSQL vs SQLite; set it to opensearch to switch. Only worth adding when recording volume makes the database's FTS a bottleneck.

Redis

Not currently used

Could serve as a Messenger transport or session cache. Not needed at current scale since PostgreSQL handles the message queue and Mercure handles all real-time IPC.

RabbitMQ

Not currently used

Could replace the Doctrine transport for Symfony Messenger if message volume or delivery guarantees require it. Messenger supports AMQP out of the box.

Why both Mercure and PostgreSQL?

Mercure (SSE) is fire-and-forget: if no subscriber is listening when an event is published, it's gone. That's fine for live UI flourishes — if a chat token delta is missed, the persisted assistant message in PostgreSQL is the canonical record on the next page load.

Transcription jobs need durability: if the worker is restarting during a deploy when a recording is uploaded, the job must not be lost. Symfony Messenger with the Doctrine transport stores messages in a database table and delivers them when the worker comes back. It also provides automatic retry with backoff and a failure transport for dead-letter inspection.

Rule of thumb: use Mercure for real-time fan-out where loss is tolerable, Messenger for background jobs where reliability matters, PostgreSQL for canonical state.

Why not use Mercure as the job queue too?

Tempting — the worker already publishes to Mercure, so why not have it subscribe for new jobs instead of leaning on PostgreSQL LISTEN/NOTIFY? Nothing stops a server-side process from subscribing to a hub; Mercure isn't browser-only, and server-to-server eventing over it is perfectly legitimate. But Mercure is broadcast pub/sub, and a work queue needs three things it doesn't provide:

  • Competing consumers. Every subscriber to a topic receives every message. Run two worker replicas and a single upload gets transcribed twice — there's no "one worker claims this job" semantics like SELECT … FOR UPDATE SKIP LOCKED or an AMQP work queue.
  • Acknowledgment and redelivery. If a worker crashes mid-job, a broker redelivers it. Mercure has no per-message ack — once the SSE frame is received, it's gone.
  • Durability. The hub can replay a bounded history via Last-Event-ID, but that's a best-effort buffer, not a log a job can be trusted to survive an arbitrary outage in.

So Mercure could only ever be a wake signal sitting on top of the durable table — exactly LISTEN/NOTIFY's role, but as custom code with the hub now in the job path. The real distinction is broadcast events vs. work distribution, not internal vs. external: Mercure for fan-out where loss is tolerable (including between server components), a broker or the Doctrine table for work that must be processed once and never lost.

Data Flows

Transcription Flow

From browser audio recording to searchable text. Recording length is bounded by OpenAI's 25 MB per-request transcription limit, not by a timer: capturing mono 24 kbps Opus — all Whisper can use, since it downsamples to 16 kHz mono regardless — puts roughly 2 hours 20 minutes inside a 24 MB cap, which is why chunking long audio and reconciling the transcripts is not part of this flow.

Browser Web Container Worker OpenAI | | | | | POST /recordings/new | | | |-------------------------->| | | | save audio-->| Network Storage | | | dispatch-->| PostgreSQL (Messenger) | | | redirect | | | |<--------------------------| | | | | LISTEN/NOTIFY | | | |------------------------->| | | SSE: "transcribing" | publish | | |<------------- Mercure ----|<-------------------------| | | | read audio-|-> Network Storage | | | | Whisper API | | | |----------------------->| | | | transcription text | | | |<-----------------------| | | save text-->| PostgreSQL | | SSE: "completed" | publish | | |<------------- Mercure ----|<-------------------------| | | | delete audio->| Network Storage | | | (kept on failure, downloadable) |

Rewriting Chat Flow

The chat lives inline on the recording page. There is one AiSession per (user, recording) and a child AiMessage per turn. When the user posts a message, the same HTTP request persists it, calls the AI provider, and publishes deltas to a per-session Mercure topic as they arrive. The browser subscribes to that topic and stitches deltas onto the assistant bubble in real time. ignore_user_abort(true) ensures the assistant message gets persisted even if the browser closes mid-stream.

Browser Web Container AI Provider | | | | EventSource: ai-session | | |-------------------------->| | | | | | POST /messages {content} | | |-------------------------->| persist user AiMessage | | |--publish "user" event --->| | SSE: user message echo | | |<------------- Mercure ----| | | | | | | persist empty assistant msg | | |---------- streamCompletion -->| | | | | SSE: "delta" tokens |<---- token chunks -----------| |<------------- Mercure ----|--publish per chunk --->| | | | | | save assistant content | | SSE: "done" |--publish --->| |<------------- Mercure ----| | | 204 No Content | | |<--------------------------| |

When to Swap What

Everything runs on PostgreSQL + Mercure today. Here's when each component should be replaced.

Bottleneck Current Swap to When
Message queue throughput Doctrine transport (PostgreSQL) RabbitMQ (AMQP) High message volume, need dead-letter queues, priority routing
Concurrent rewriting chats PHP-FPM worker held during the AI stream FrankenPHP worker mode FPM worker pool exhausted under heavy concurrent chat load — FrankenPHP keeps the same worker available across requests
Full-text search PostgreSQL FTS OpenSearch Large recording corpus, need fuzzy matching, faceted search, or relevance tuning
API key encryption Vault KMS (prod) / sodium (dev) External KMS (AWS, GCP) Multi-cloud deployment, compliance requires HSM-backed keys
Audio storage Symfony Cloud network-storage S3-compatible object store Need CDN, cross-region replication, or very large file counts

Why Display, Not Execute

An earlier iteration ran a full coding agent (pi.dev) inside a browser-side terminal — PTY forwarding, bubblewrap sandboxing, a dedicated worker container, the works. It was the wrong shape for what users actually want here.

What the user wants

The job is "rewrite this transcript" — tighten it, remove filler, restructure, summarize. Not "execute commands on a sandbox," not "research the web," not "build software." Once you accept that, almost everything else simplifies.

The chat is just text

Replies arrive as token deltas, get persisted to ai_message, and render as sanitized markdown. The application never executes anything the model produces. That removes the whole sandboxing problem — no bubblewrap, no per-user filesystem isolation, no PTY multiplexing, no worker container.

One conversation per recording

Each (user, recording) pair maps to exactly one persisted AiSession. Reload the page and you get the same thread back; open it in another tab and Mercure keeps both views in sync. No session list, no "+ New conversation" UI, no session lifecycle states — the data model becomes a parent row plus an ordered list of messages.

What we removed

The ai-session Messenger transport, the dedicated worker container, the pi.dev binary, the bubblewrap install, the xterm.js terminal, the Revolt event loop dependency, two HTTP control endpoints, and a full second page in the routing tree. The schema lost a status enum and a closed_at column.

Skills as system-prompt augmentation

Neither Anthropic Messages nor OpenAI Chat Completions has a first-class "skill" parameter — it's a client-side concept (Claude Code, Cursor, etc. all do this). Each Skill is a name + free-form instructions document scoped to a user. Active skills for a session live in an ai_session_skill join table.

Before each provider call, buildSystemPrompt(AiSession) concatenates the base writing-assistant prompt with each active skill's instructions under ## Skill: name headers. That string lands in Anthropic's top-level system field or OpenAI's leading role: system message — both providers treat it as foundational context for every reply. Skills are never visible in the chat history; they only shape the assistant's behavior.

Automated Dependency Updates

The site keeps itself updated via a weekly CI job that creates a branch, updates dependencies, runs the test suite, and decides what to do based on the results.

Weekly cron trigger

An Symfony Cloud cron job runs app:ci-run every Monday at 3 AM. It uses the Symfony Cloud API to create a new environment branch from main, so the update process runs in an isolated copy of production.

Source operation: update dependencies

The CI job triggers a source operation that runs composer update on the branch. This updates composer.lock and commits the changes. The source operation log captures which packages were updated and whether any have security advisories.

Deploy and test

After the source operation, Symfony Cloud deploys the branch. The post_deploy hook runs PHPUnit. If the deploy or tests fail, the branch is left in place for investigation.

Security fixes: auto-merge

If the source operation log mentions security vulnerability advisories, and the tests pass, the branch is automatically merged back into main and cleaned up. A notification email confirms what was merged.

Non-security updates: notify and wait

If no security advisories were found, the branch is left open and a notification email is sent with a direct link to review and merge in the Symfony Cloud console. This avoids auto-merging routine updates that might need manual review.

AI-powered failure analysis

When any step fails (branch creation, source operation, deploy, or tests), the activity log is sent to OpenAI for analysis. The AI identifies the likely root cause and suggests fixes. This analysis is included in the failure notification email, so the developer gets actionable context without having to dig through raw logs.

CI Run Flow

Cron (Monday 3 AM) Symfony Cloud API OpenAI | | | | app:ci-run | | |-------------------------->| branch from main | | | source op: composer update| | | deploy branch | | | (post_deploy: phpunit) | | | | | read activity logs | | |<--------------------------| | | | | |--- security advisory? ----| | | YES: merge + email | | | NO: email w/ link | | | | | |--- failure? --------------| | | send log | | |------------------------------------------>| | | AI analysis | | |<------------------------------------------| | | email w/ analysis | |

FrankenPHP Migration Path

The chat handler holds a PHP-FPM worker for the duration of an AI completion (typically a few seconds, occasionally tens of seconds for long replies). At low concurrency this is fine. Under sustained load it becomes the scaling pinch point. FrankenPHP — a modern PHP application server that keeps processes alive across requests and embeds Mercure directly — is the planned escape hatch.

Better worker utilization

FrankenPHP's worker mode keeps a single warm process serving many requests, so a slow streaming reply doesn't tie up an entire FPM child for its lifetime. Concurrent chats cost less.

Embedded Mercure

Publishing chat token deltas becomes an in-process call to a built-in Mercure hub instead of an HTTP round-trip to a separate service. Lower latency per token, fewer moving parts in deployment.

Simpler deployment

Today we run a web server, a transcription worker, and a standalone Mercure service. With FrankenPHP, the web server absorbs Mercure, leaving the transcription worker as the only sidecar.