Engineering
Technical Decisions
Status: Living document (ADR log) · Owner: Engineering · Last reviewed: 2026-07-08
Architecture Decision Records. Each entry captures a decision, why it was made, and its consequences. Newest last.
ADR-001 — Next.js 16 App Router on Vercel
Decision. Build the whole product (public site + Studio + APIs) as one Next.js 16 App Router project deployed on Vercel.
Why. Server components keep the gallery fast and SEO-friendly; Route Handlers give us a single place for guarded APIs; Vercel provides TLS, CDN, DDoS/WAF, and horizontal scaling with zero infrastructure to operate.
Consequences. No separate API gateway or load balancer is required — the platform provides SSL termination, routing, and scaling natively.
ADR-002 — Neon Postgres via Drizzle; promoted to normalized tables
Decision. Originally the entire Studio state was persisted as a single JSONB
row (studio_state). As of July 2026 (commit 8c8b03b) the photo and IG-post
data has been promoted to proper relational tables (photos and ig_posts).
studio_state now holds only lightweight structured data: settings, collections,
special days, and the activity log.
Why the promotion. The JSONB snapshot became a bottleneck: the entire blob had to be loaded for every read, and individual photo fields (taglines, voices, scores) couldn't be queried or updated atomically. Normalized tables let agents update a single row without touching the snapshot, and enable efficient per-photo queries for the Monitor and Approvals tabs.
Consequences. photos carries all site-facing fields including siteVoices
and activeSiteVoice (for site-only showcase frames) and analysisEmphasis. ig_posts
carries per-Instagram-post data (voices, hashtags, status, schedule). Writes to
these tables go through upsertPhotos / upsertIgPosts with onConflictDoUpdate
so the server pipeline and the client orchestrator stay in sync. Optimistic
concurrency (ADR-003) still guards the remaining studio_state writes.
ADR-003 — Optimistic concurrency + Postgres advisory lock
Decision. Every state write bumps version; conditional updates detect lost
writes, and a Postgres advisory lock serializes concurrent writers.
Why. Webhooks, cron, and the operator can all write at once. Optimistic versioning plus an advisory lock gives correctness without a heavyweight queue.
Consequences. Callers must handle version-conflict reconciliation. Writes are effectively single-flight, which is fine at single-operator scale.
ADR-004 — Dual identity with MFA delegated to Microsoft Entra ID
Decision. Support a password-derived HMAC session cookie for the operator and Microsoft Entra ID OAuth 2.0 (authorization-code + signed CSRF state) for Graph-backed features. MFA is enforced by Entra ID, not the app. All identities are checked against an email allowlist.
Why. The app must call Microsoft Graph (OneDrive) on the operator's behalf, so Entra ID is the natural identity provider — and it gives us enterprise-grade MFA for free. Delegating the second factor keeps sensitive auth out of our code.
Consequences. Microsoft refresh tokens are encrypted at rest (AES-GCM,
key derived from STUDIO_SECRET) so background jobs can mint Graph tokens
without a browser tab open.
ADR-005 — Edge middleware auth guard + shared API guard
Decision. Verify the session at the edge (middleware) before requests reach a function, and wrap every Route Handler in a shared guard: session check → per-IP rate limit → Zod validation → timeout/retry → leak-proof error envelope.
Why. Defense in depth and consistency. One guard means every endpoint gets the same protections; the edge check rejects unauthenticated traffic early.
Consequences. New endpoints opt into the guard rather than re-implementing auth, validation, and error handling.
ADR-006 — Resilient outbound calls
Decision. All third-party calls (Graph, AI Gateway, Instagram) go through a shared client with timeouts and bounded retries; non-idempotent calls (e.g. Instagram publish) never retry. SSRF protections gate outbound URLs.
Why. External services fail and rate-limit. Bounded retries improve reliability; skipping retries on publish prevents duplicate posts.
Consequences. Predictable failure modes; AI agents additionally fall back to a deterministic mock so ingestion never hard-fails.
ADR-007 — Deterministic, cached AI agents
Decision. Agent results are memoized in agent_cache, keyed by a hash of
(agent + system prompt + input + image identity).
Why. A photo should only be processed once; the model call is the biggest
cost lever. Caching makes runs deterministic and cheap. The image URL must
be part of the key — the text input is only {name, w, h}, which collides
across photos that share a common camera resolution (e.g. 1600×1067) and would
otherwise serve one photo another's analysis. A stale mock entry is also
never returned once a real provider is configured.
Consequences. Cache invalidation is explicit (change the prompt/input/image to recompute). Telemetry records provider and token usage per call.
ADR-008 — Private Blob for originals + cached public rendition proxy
Decision. Store directly-uploaded portfolio originals in a private
Vercel Blob store (BLOB_PRIVATE_READ_WRITE_TOKEN). On ingest, jimp derives
downscaled renditions (web ≤1600px, thumbnail ≤600px, Instagram 1080px); HEIC/
HEIF is transcoded to JPEG first (pure-JS heic-convert, no native binary — see
ADR-010). Public surfaces are served only the renditions, through an
edge-cached proxy (/api/media/[...path]) that streams web/thumb from the
private store and hard-blocks any request for an original (404). Originals
return 403 to the public internet.
Cloud-synced photos (OneDrive / Google Drive) follow a different retention model for the original — see ADR-009. The rendition serving path below is identical for both sources.
Why. Portfolio full-resolution files are the photographer's IP and must not be downloadable by anyone with a URL. A private store gives least-privilege, Vercel-native access control (analogous to a private bucket) without exposing raw files, while the CDN-cached proxy keeps the public gallery fast.
Consequences. The AI Gateway can't fetch a private URL, so agents read the image bytes server-side and inline them as a base64 data URI — the image is analyzed without ever being publicly reachable. Instagram publishing generates the crop from those bytes and exposes it only via a short-lived signed media URL. Delete purges the stored renditions (and, for uploads, the original) together.
ADR-009 — Cloud-synced originals stay on the provider (rendition-only retention)
Decision. For photos synced from OneDrive or Google Drive, we do not
permanently store the full-resolution original on our infrastructure. On each
sync we download the bytes once, derive the web/thumb renditions into the
private Blob store (ADR-008), run the AI pipeline against the bytes, then
delete the briefly-staged original. Only the small renditions persist on our
side; the original's lasting home stays the photographer's own drive. The DB
records /api/media proxy paths — never a provider URL. Directly-uploaded
photos are unaffected (their original is retained per ADR-008).
Why.
- Privacy / IP. The photographer's full-res library never lives on our servers — a strong, honest guarantee we can extend to future multi-tenant users ("we only analyze your photos; we never keep your originals").
- Correctness. Provider download URLs are short-lived — OneDrive's
@microsoft.graph.downloadUrlexpires in ~1 hour and Google'salt=medianeeds an auth header. Persisting those URLs (the previous behavior) meant OneDrive galleries broke ~1 hour after sync. Serving our own renditions removes any runtime dependence on the provider. - Cost. Originals are ~95% of the storage footprint; keeping only renditions slashes Blob cost and sidesteps retention/folder-structure concerns.
How. lib/providers/media-import.ts downloads (bearer only for providers
whose bytes are OAuth-scoped — Google requiresBlobImport=true; OneDrive URLs
are pre-authenticated and fetched with no header), calls
storePhotoAssets() to produce renditions + a staged original, and returns the
staged pathname. lib/server-pipeline.ts (headless) and
/api/studio/drive/materialize (interactive) both delete that staged original
in a finally block once processing completes. Stable provider item IDs
(not URLs) are what we retain, so a fresh download URL can always be minted on
demand if a re-import is ever needed.
Consequences.
- The site never shows broken images from expired provider URLs.
- If the photographer deletes a file from their drive after sync, our renditions remain (we don't propagate deletes — consistent with prior behavior).
- If no private Blob store is configured, import falls back to the vendor URL (legacy, short-lived) for non-HEIC files; HEIC has no safe fallback and surfaces an error, since a raw HEIC URL won't render in browsers.
- Serving originals directly from a provider in a future multi-tenant product would require "anyone-with-link" share URLs (less private); the rendition-in-blob approach is deliberately the more privacy-preserving design.
ADR-010 — Server-side HEIC/HEIF decoding without native binaries
Decision. Transcode HEIC/HEIF to JPEG with heic-convert (pure
JS/WASM, backed by libheif-js) in lib/heic.ts, used by both the upload
rendition path and the cloud-import path. We explicitly avoid sharp for
this.
Why. jimp (our renditioning library) has no HEIC decoder, and iPhones
shoot HEIC by default. sharp can decode HEIC but ships prebuilt native
binaries that must match the serverless runtime's architecture and glibc; a
mismatch throws at cold start on Vercel. heic-convert has no native binary, so
it runs identically everywhere — including headless cloud-sync jobs where no
browser is available to convert.
Consequences. HEIC works for every ingestion path (direct upload, OneDrive, Google Drive). Conversion is CPU-bound WASM; acceptable for the one-shot ingest of a single photo. The private store handles the transcode, so the no-store fallback (ADR-009) cannot serve HEIC and errors instead.
ADR-011 — Single vision model + text-LLM pipeline (Gemini 2.5 Flash)
Decision. Use one model — google/gemini-2.5-flash — for the entire AI
pipeline, called in two modes:
- Vision mode (Curator only): image bytes inlined server-side as base64 +
text prompt →
ContentIndex+ symbolism. - Text-LLM mode (Editor, Copywriter, Scheduler, Hashtag Strategist): text input only → structured JSON output via Zod schema.
The Copywriter also receives the image inline despite being a "text" agent — to ground its three voice variants in what is literally visible in the frame.
All calls go through the Vercel AI Gateway (AI_GATEWAY_API_KEY) using the
AI SDK's generateObject. No direct Gemini SDK — the gateway handles auth,
retries, and rate-limit headers.
Why one model. Using a single model for all agents removes the complexity of managing multiple API keys, billing accounts, and capability gaps between providers. Gemini 2.5 Flash handles both the vision task (Curator) and the structured text tasks (all other agents) with acceptable quality at low cost.
Why Gemini 2.5 Flash specifically. Best token-cost ratio among gateway-
available vision models at the time of selection; native multimodal support in a
single call; compatible with generateObject structured output; available
zero-config through the Vercel AI Gateway without a separate provider API key.
Why image bytes, not URLs. The AI Gateway's servers cannot reach our private
Vercel Blob renditions (served via a relative proxy path) or short-lived cloud
provider URLs. Inlining the image as base64 server-side is the only reliable path.
Magic-byte MIME sniffing overrides unreliable server-sent Content-Type headers.
Why structured output only. generateObject with a Zod schema enforces the
contract at the call site. A malformed response throws immediately with a clear
error, rather than silently propagating bad data through the pipeline. There is
no free-text fallback or mock path.
Consequences. All token usage, cost, and latency are recorded in agent_runs
and surfaced in the Monitor tab. Results are memoized in agent_cache keyed by a
SHA-256 of (agent + prompt + input + imageUrl) — the image URL is part of the
key to prevent photos that share identical metadata (same camera, same resolution)
from colliding. To swap models in the future, change LIVE_MODEL in lib/model.ts
and update the price table in lib/pricing.ts.
Known trade-offs / follow-ups
- Rate limiting is in-memory (per-instance). For strict global limits in production, back it with Upstash Redis. See the Roadmap.
- Neon connections: use the pooled endpoint in production to avoid exhausting connections under serverless concurrency.