A Story in Seven Chapters

The Life of This App

Seven commits, one coherent system. The story of aatm-next — chassis to publish loop.

What we're talking about

aatm-next is a multi-tenant content workspace for opinionated, AI-assisted long-form writing. The shape was there in the first commit and has not moved since: workspaces own sources, voices, content formats, and content; content owns versioned drafts; drafts go through review and approval; approved drafts become canonical and are ingested into a per-workspace RAG namespace. Two AI surfaces (DraftAI for writing, WorkspaceAI for read-only Q&A) sit on top. Public share tokens let outside reviewers comment without auth. That description is true today and it was true on day one.

What changed across seven pushes is everything under that description — how the AI actually writes, where the markdown lives, how images and embeds work, how an approved draft leaves the system, and which conventions calcified into load-bearing contracts.

This is the story.

· · ·
Chapter 1 architectural diagram: The Chassis. Multi-tenant AI writing workspace showing workspaces, sources, voices, formats, content feeding into a Convex core, with Drafts → Review → Approved leading to RAG and the two AI surfaces.

I. The Chassis

Commit 1 · The first push

The codebase was born almost fully grown. Multi-tenant auth via Clerk + workspace memberships. Three source pipelines (text-paste, URL-scrape, file-upload) feeding versioned bodies into Bedrock-Titan-v2 RAG. Voices as prompt fragments. Content formats as deletable-but-key-pinned templates. A two-state draft ladder (drafting | approved) with derived review states. Public-and-member comments hitting one table, public-and-member presence hitting one face-pile. An agent stub. A RAG namespace per workspace. A V8/Node runtime split where Node owns LLM and extraction and V8 owns CRUD and lifecycle.

The first review found 18 things, most of them small. The biggest one was almost embarrassing: displayName got stomped on every authed mutation, because every authed mutation flowed through an upsert that rewrote the user row from Clerk identity claims. A token of dev-tenant URL was hardcoded in auth.config.ts. Share-link tokens were minted with Math.random(). A handful of indices were missing. The agent's tools were declared but never registered, and the chat action had no auth at all.

The patterns worth standardizing on were already there too: soft-delete with a single generic cascadeRemoveKeys primitive. The same comment table powering both member and guest comments. The lifecycle gate (assertCanEditDraftBody) firing in every writer rather than at the UI. "Public surface returns null for everything wrong" so a token can't be fingerprinted. Curated public payloads that strip workspace ids, sibling versions, emails.

The skeleton was good. The skin needed work.

Chapter 2 architectural diagram: Hardening. Closing early risks and codifying rules. Shows secure token generation, role helpers, lifecycle gates, idempotent mutations, two-path user identity sync, and displayName provenance — with the unresolved public guest comment rate limit flagged.

II. Hardening

Commit 2 · Closing the early risks

Thirteen of the eighteen findings closed cleanly. Two partially. Two by design.

The displayName fix is the most diagnostic of how this codebase absorbs feedback. The bad version ran an unconditional patch. The fixed version split the helper in two: an insert-only ensureUserExists for auth-time, and a webhook-only syncUserFromClerk that respects displayNameSource: "user" | "clerk" provenance flags. A new updateMyDisplayName mutation gave the UI a real edit path. The fix didn't just close the bug, it taught the system that some fields are locally edited and some are imported.

Math.random() was replaced by crypto.getRandomValues across share tokens, invitation tokens, and slug suffixes. The Clerk issuer moved to an env var. chatActions.sendMessage got a real auth gate (assertThreadInWorkspaceForAction). The aiThreads table got a composite index. Role-tier helpers (requireWorkspaceWriter, requireWorkspaceAdmin) appeared but went mostly unused — viewers could still write everything. The agent's tools were registered but their bodies still returned (integration pending).

Two new conventions made it into the canon here: mutation idempotency (every "delete this" / "withdraw this" / "resolve this" returns silently when the requested transition is a no-op, because Convex retries on transient failures) and lifecycle gates live in mutations, not callers (the editor's readOnly is belt-and-suspenders; the wall is the server).

The thing-that-wouldn't-die also showed up here for the first time: the public guest comment surface still had no rate limit. The code openly acknowledged it. The reviewer flagged it. It would be flagged again in every subsequent review.

Chapter 3 architectural diagram: The AI Starts Writing. DraftAI proposals, hunks, and audit trail. Shows the AI Extraction → Convex handshake, hunk-by-hunk Changes Panel, revert path with history intact, draft body projection, and the Flush Before Reads pipeline.

III. The AI Starts Writing

Commit 3 · 14k insertions, 69 files

This was the big jump. The shift in one sentence: the AI moved from "registered but stubbed" to "writes the body, decides per-hunk, reverts cleanly, audits everything."

The mechanics: update_draft produces a draftAiProposals row → a ProposalCard in the chat panel → a ProposalBanner at the top of the editor → inline hunk decorations via proposalHunksExtension → a side panel (ChangesPanel) that lets the reviewer accept and reject hunks individually → revert via inverse-row insertion (revertedFromProposalId) rather than in-place flips, so the audit log preserves both the original accept and the revert as distinct events.

Underneath, a structural decision: drafts.body flipped from canonical state to projection. @convex-dev/prosemirror-sync now owns live state. Markdown is derived. The four-tier AI scheme (workspace / content / source / voice) collapsed to two: WorkspaceAI (read-only across the whole RAG corpus) and DraftAI (write tools, narrow grounding). The viewer role tier was deleted entirely — closing the unfinished authorization audit from commit 2 by removing the tier that needed it.

A pile of new patterns crystallized:

  • Internal-mutation ↔ Node-action handshake. The Node action computes the diff; the V8 mutation inserts the row. Same shape as ingest paths.
  • One canonicalization for equality, original kept for display. canonicalizeForEquality (NFC + escape-strip + whitespace-collapse) feeds the LCS; hunks emit the original strings. Killed phantom diffs from markdown round-trip noise.
  • Lifecycle hooks broadcast to dependents. supersedeOpenProposals fires from requestReview and approveDraft.
  • Tool subsetting at the SDK boundary. WorkspaceAI's model literally cannot see update_draft because it's not in its tool schema — refusing to write became structural, not behavioral.
  • flushDraftEditor() before any AI/RAG read of the body. The 800ms autosave debounce was the source of a "HELLO disappeared" bug.

The review noted three callouts: the _*.ts verification matrix files (which would haunt every subsequent review), the public comment rate limit (still gone), and the draftRevisions table (writers landed without readers).

Chapter 4 architectural diagram: The First Opinionated Workflow. SEO brief to strategy-driven draft. Three-tier parse cascade, structured strategy, atomic spawn that mints content, draft, DraftAI thread, and scheduled kickoff in one transaction. Comments and voice handoff to AI.

IV. The First Opinionated Workflow

Commit 4 · The SEO writer

If commit 3 made the capability (AI proposes body), commit 4 made the use case. Paste an SEO brief → parse into structured rows → action one → mint a Content + draft + DraftAI thread + scheduled kickoff turn → iterate forever after under a per-draft system fragment that keeps the model on-message.

The new patterns transferred well. A three-tier parse cascade for arbitrary AI ingest (known-shape adapter → native short-circuit → LLM with structured-output + text-fallback). A single-mutation transactional spawn that mints content, draft, thread, and a scheduled action in one atomic shot — Convex's scheduler only commits if the outer transaction commits, so a mid-spawn throw leaves nothing orphaned. An auth-skipping internal action (sendKickoffMessage) for scheduler-spawned LLM calls, complete with a "skipped vs preserved" matrix in the file header so it could never accidentally widen.

The shape of the SEO writer's lifetime was set by one schema field: drafts.seoStrategyId. Once seeded, never cleared. Every DraftAI turn re-loads the strategy and prepends the SEO system fragment. The schema comment carried the contract in prose: "Never cleared — the draft's entire lifetime is SEO-aware once seeded." Same shape as the schema comments that already forbade re-introducing content.activeDraftId / voiceId / formatKey — load-bearing prose, treated as review-gating.

Several quieter wins shipped alongside. [[verify: …]] placeholders as the AI's "I don't know this for sure" channel back to the human, with a TipTap extension, a rehype plugin, a count pill that doubles as click-to-jump. Voice dictation in comments, plus inline edit, plus per-comment "Ask AI to address" handoff. The Lens magnifier toggle fixed via display: contents (always-mount instead of conditional-tree-shape) so flipping it stopped remounting the editor and cold-starting TipTap. A callback-identity bug in useDebouncedMarkdownSave that had been silently dropping AI-applied bodies until the user typed.

The recurring thread acquired more weight: now FOUR pushes without a public comment rate limit, on a surface that had just gained voice dictation.

Chapter 5 architectural diagram: Surgery. Server-side snapshot to markdown. Old fragile client-side body saving replaced with new strong server-side snapshot and atomic markdown commit. baseBodyHash replaced by baseSyncVersion. Shared runtime-neutral helpers.

V. Surgery

Commit 5 · Server-side derivation

Single-topic and load-bearing: server-side onSnapshot markdown derivation. Five new modules in convex/lib/. A V8-safe two-direction PM↔markdown bridge (snapshotMarkdown.ts). A fresh-body read helper that bypasses the snapshot debounce (freshBody.ts). The only sanctioned shape for server-side PM-sync replacement (proposalTransform.ts). A single source of truth for timing constants (timing.ts). The useDebouncedMarkdownSave hook, the flushDraftEditor registry slot, and the updateDraftBody mutation were deleted.

The pre-commit-5 fragility: four conditions had to hold for the markdown projection to converge with the PM doc — tab open, debounce timer surviving, every reader awaiting flushDraftEditor, and the mutation actually committing. Three of those were outside our control. Post-commit-5, the writeback is the same atomic operation as the snapshot accept. The PM doc cannot move forward without the markdown moving forward.

The proposal pipeline became serializer-independent. baseBodyHash was replaced by baseSyncVersion (PM-sync version compare) — Phase 6 clean wipe rather than a deprecation cycle, because the audit found zero readers. update_draft's body field became optional, killing the "AI re-emits and incidentally re-tokenizes *emphasis* to _emphasis_" class of bug structurally rather than via prompt engineering.

The AI's title-only proposals stopped being "trust the AI's narration" — the side panel's DiffPair got reused inline so the user sees the title before/after at the deciding surface. The Regenerate button on proposals got deleted entirely; the two-button card (Apply / Discard) now does exactly what each label says.

A whole class of disciplines crystallized here. Two-direction modules per format boundary (with a "Recipe for adding a new node/mark type later" header listing every co-change). Loud-fallback parsing + round-trip stability matrices as deploy gates — though the matrices shipped as _*.ts underscore-prefixed internal actions, "throwaway, dashboard-only," with Phase 7's permanent harness deferred. Asymmetric body-read API by call-site intentapplyProposal deliberately uses the snapshot-debounced getBody because its staleness gate must NOT see the AI's own in-flight setContent steps; revert paths use readFreshBodyMarkdown because their splice math must match the editor. Same field, two reads, different freshness contracts, named explicitly at the call site.

The watch list grew. Now five pushes without a public comment rate limit. The matrix-without-a-permanent-home was named the top item for commit 6. baseSyncVersion started its multi-commit identity crisis ("diagnostic-only today" — but for what, exactly?).

Chapter 6 architectural diagram: The Media Tier. Assets, featured images, and embeds. Cloudflare R2 storage with custom workspace-keyed paths, marker convention, render-time resolution, four-tab media picker, six-provider embed registry, and a three-step upload flow.

VI. The Media Tier

Commit 6 · R2, markers, embeds

Three contiguous commits, the largest of which built the media tier end to end on top of @convex-dev/r2. A mediaAssets table with custom workspace-keyed paths. A per-draft Featured Image slot. Inline body images via {{MEDIA_BASE}}/<mediaId>/<filename> markers. External video / podcast / audio embeds covering YouTube, Vimeo, Wistia, Spotify, Apple Podcasts, generic audio — six providers, one registry, markdown-link-as-medium round-trip. A workspace Media Library page. A four-tabbed picker dialog (Library / Upload / URL / Embed) shared between two toolbar buttons and the Featured Image slot.

The conventions:

  • Marker convention for first-party body references. {{MEDIA_BASE}}/<id>/<file> for body images, {{FEATURED_IMAGE_URL}} for the JSON-LD cover-image field. Curly-double = downstream pipeline substitutes. Bracket-double ([[verify: …]]) = a human resolves. Visually distinct shapes prevent the failure mode where one becomes the other in copy-paste.
  • Two-tier src semantics resolved at the NodeView, opaque at every other layer. Marker (workspace asset, future export-bundle target) vs external URL (third-party) at the live editor; the V8 layer treats src as an opaque string.
  • Self-heal on render rather than ship a migration. The NodeView rewrites percent-encoded markers to canonical form on first mount. The PM doc converges to clean state downstream without a server sweep.
  • Schema-only extension in convex/lib/ + live extension that extends it. Three-way lockstep (V8 schema, bootstrap path, live editor) enforced by the verification matrix.
  • Provider registry as single source of truth for cross-surface format detection. Adding a provider = one entry, no other file edits. First-match-wins walk order.
  • Embed-as-link round-trip strategy. The serializer writes [Provider · title](url); the parser walks paragraphs with the one-text-node-one-link-mark gate and promotes back to embed nodes. Markdown viewers without our convention see clickable links and reach the source; our editor renders an iframe / audio.
  • Pre-create-row + presigned PUT + finalize-mutation upload contract. The pending row IS the orphan-prevention primitive.

The cad72b7 follow-up taught the matrix's value the day it shipped. SNAPSHOT_SCHEMA was missing inline: true on Image (CommonMark's image grammar is inline; without the configuration Node.createAndFill silently dropped images on parse). _smokeTest's schema-with/without builds were configured wrong, hiding the drift. _parserVerify was calling SNAPSHOT_PARSER.parse directly, bypassing the embed promotion pass and only exercising the link round-trip. The fix was three lines and a wiring change. After: moduleSchemaConsistent: true, okCount: 27 / 32 across the matrices, failCount: 0. The matrix paid for itself the moment it was actually wired correctly.

The recurring thread now had its own footnote: SIX pushes without a public comment rate limit. The matrix's permanent home was now overdue for the second push in a row. baseSyncVersion was still "diagnostic-only."

Chapter 7 architectural diagram: The Publish Loop. Endpoints, snippets, and push jobs. Endpoints table feeding pushJobs queue, marker resolution, two image strategies (proxy vs push), final v1 envelope POSTed to articleUrl. Verify marker gate and snippets AI surface.

VII. The Publish Loop

Commit 7 · The outbound surface

What the previous six pushes had been pointing at. An editor-configured endpoints table feeds a queue (pushJobs) consumed by a Node executor (pushActions). The executor resolves every body / snippet / featured-image marker against one of two image strategies — proxy (we host bytes forever via a /m/<mediaId>/<filename> HTTP route that 302s to a 24h presigned R2 URL; bytes stay on R2) or push (we POST bytes to the customer's imageUploadUrl and rewrite markers to whatever URL it returns). Then a versioned Envelope (body + snippets + featured + manifest, version: "1") lands at the customer's articleUrl.

Both gates that existed — approveDraft and executePush — refuse to proceed when any [[verify: …]] placeholder remains, sharing a single scanner from lib/verifyMarkerScan.ts. The verify-marker workflow now runs through six surfaces: editor amber pill, snippets-toggle badge, RequestReviewModal self-approve gate, ReviewerBanner Approve gate, approveDraft server mutation, executePush action. The path to a customer's CMS that bypasses the scanner does not exist.

A second, separate AI surface landed alongside: the Snippets panel. A standalone surface that reads the draft as it currently is and produces typed metaTitle / metaDescription / slug / jsonLd[] onto drafts.snippets. Per-draft (so A/B fork-versions carry separate snippets), persisted, surfaced in a third right-rail drawer alongside Comments and Pushes. Mutual exclusion between the three drawers; one slot, three lives.

The patterns this push contributed:

  • Atomic claim-and-flip mutation for scheduler-fired actions. The read AND the status flip AND the attempts increment in one internal mutation. No TOCTOU window for retry storms.
  • Cancel-via-status-flip, not scheduler cancel. Convex's scheduler doesn't expose per-job cancellation; the queue table's status column is the cancellation primitive, the executor's claim-mutation status check is the enforcement point.
  • Single-call bundled-read internal queries for Node action contexts. _readPushContext, _readSnippetGenerationContext — auth + draft + body + assets + featured, all in one transaction.
  • Three-state "settled vs unresolved vs absent" output convention. Featured-image branch in three prompt builders now: substitution-token if set+resolved, verify-placeholder if set+dangling, verify-placeholder if absent. Branch on actual current state, never on column presence alone.
  • Tristate sentinel for partial-patch mutations (undefined / null / value).
  • Versioned envelope as cross-app contract surface. version: "1" is the explicit hook for evolution. The images[] manifest is the secondary contract for customers who want to rehost on receive.
  • Idempotency-Key derived from most-specific stable identifier. draftId:versionNumber for the article POST, nested for assets.
  • Header-injection-safe header-name validation for any "user supplies a header name we'll send on their behalf" field.

Two pieces deliberately deferred: HMAC payload signing (auth is bearer-token-in-header today; integrity verification is the customer's call), and KMS-grade secret handling for endpoints.authToken (plaintext-in-DB acknowledged in the module header, with a workspaceSecrets migration path named).

· · ·

The threads that ran the whole way

The two-state ladder. drafting | approved survived seven commits. Pressure to add a published status was resisted explicitly. The derived states (in review, changes requested, published-via-share, pushed-to-endpoint) all read from reviewerUserId / draftReviewEvents / pushJobs, never from a status enum. A lot of competing products have a published column they wish they didn't.

Soft-delete + cascade-by-key as one primitive. cascadeRemoveKeys took a { workspaceId, keys: string[] } shape on day one and never needed to grow. Sources, drafts, content, voices, formats, and now strategies and media all flow through it. Proposals deliberately get NO deletedAt because their status enum is the only lifecycle vector ("a second soft-delete vector would let proposals slip into a state outside the discriminator and silently vanish from listOpen"). This restraint is itself a load-bearing convention.

Schema comments as enforcement. content carries explicit "do NOT re-introduce activeDraftId / voiceId / formatKey / threadId" prose. drafts.seoStrategyId carries "Never cleared." draftAiProposals carries "no deletedAt." mediaAssets.contentId carries the first-association back-fill rule. These are treated as review-gating contracts.

The convex/lib/ folder. Empty in commit 1. By commit 7, seventeen files. The convention is "shared, runtime-neutral, V8-safe" — no Convex types, no "use node", importable from anywhere.

Defense-in-depth on assertCanEditDraftBody. Re-fired in updateDraftBody, updateDraftMeta, applyProposal, acceptHunk, rejectHunk, draftSync.checkWrite, setFeaturedMedia, the editor's readOnly prop. Every new writer that landed across seven commits honored it.

One scanner per "must-resolve-before-publish" placeholder. The [[verify: …]] workflow is one rule, one matcher, six gates.

The {{...}} substitution-token vs [[verify: …]] editor-resolves distinction. Born in commit 6, load-bearing in three places by commit 7. Visually distinct so they can't be confused in copy-paste. Branch on resolved state, not column presence.

Cross-app contract documentation. The pattern of putting "what the downstream consumer expects from this AI's output" in the prompt builder's header started in commit 6 (seoPrompts.ts for the CMS) and was already overdue for promotion to a standalone doc by commit 7 (when the consumer set became open-ended via endpoints).

The threads that won't go away

Public guest comment rate limit. Flagged in commit 1. Flagged in commit 2. Flagged in commit 3. Flagged in commit 4. Flagged in commit 5. Flagged in commit 6. Flagged in commit 7. Five lines of schema, ~50 lines of mutation. The code itself acknowledges the gap inline. Every review names it as urgent before any share link goes wider than trusted reviewers. Every push of accumulating trust without it is one more push.

The _*.ts regression matrix without a permanent home. Phase 7 of the onSnapshotMarkdownDerivationPlan was supposed to fold them into a permanent serializerSpec.ts and delete the underscore files. That didn't happen. The matrix grew from four entries to thirty-two by commit 7. It demonstrably catches real bugs (the cad72b7 schema-drift fix). The contract today is "remember to invoke five internal actions before deploying." The underscore prefix says "throwaway"; the substance says "load-bearing."

baseSyncVersion's identity crisis. Schema comment says "diagnostic-only today, kept as a future hook." Five reviews now have flagged this as an unresolved decision: wire the version-bump gate, OR document explicitly what the field is FOR, before a fresh contributor reads "diagnostic-only" as "delete-on-cleanup."

Where this leaves us

The publish loop is end to end. Approved drafts can leave the system and land in customer CMSes, with a versioned envelope, image-strategy resolution, idempotency keys, and a verify-marker gate that refuses to ship unresolved placeholders into customer-facing JSON-LD. The right rail consolidates Comments / Snippets / Pushes into one slot on tablet/mobile. The Endpoints settings page closes the configuration loop the previous six pushes implied.

Reading these seven reviews together, what stands out is the discipline of the additions, not the additions themselves. Each push fit cleanly into the previous push's frame because the previous push had named what it was leaving open and the next one started by closing it (or explicitly choosing not to). The notes.md and thingsWeRanInto.md files are part of how that worked. So is the comment-as-architecture-doc style that survived 153-line refactors without rotting.

The chassis was good on day one. What grew on top of it stayed coherent because every load-bearing convention got named, the schema comments forbade the right things, the pure helpers got lifted into one folder the instant a second caller appeared, and the gates that mattered (verify markers, auth, lifecycle locks, soft-delete cascades) were enforced server-side at every writer rather than at the UI.

The story so far isn't
"we built a thing."
It's "we built a thing such
that the next thing will fit."