Convex
Convex and Convex Agents implementation rules for this codebase
References
Agent skills
Vendor a named subset of the Convex skill set
once in .agents/skills/; .claude/skills/convex* contains relative symlinks.
skills-lock.json tracks upstream paths and content hashes. Keep upstream skills
unmodified; this page and Repository Operations own repo-specific rules.
The subset is the eleven this product uses (owner ruling 2026-09-06, GB-192 R6):
convex, convex-design, convex-expert, convex-reviewer, convex-authz,
convex-test, convex-verify, convex-migrate, convex-migrate-rehearse,
convex-deploy-guard, convex-docs. Billing, domains, backups, crons, cost,
Sentinel, launch readiness and the rest are one command away when a task needs
them; every vendored skill's description is read on every turn, so the set stays
small. In plain terms: keep the precedents the practice uses on the shelf; the
rest stay in the library and are fetched when a matter calls for them.
Refresh or add from upstream with
bunx skills add get-convex/agent-skills --skill <name> --agent codex claude-code -y,
naming only skills in the list above (or adding to the list here in the same
commit). Review the diff, remove skills retired upstream (including their links
and lock entries), repair callers, and preserve upstream licenses in
.agents/skills/. Do not use --copy or maintain a second tree. Installing
skills does not authorize deployments, production changes or sending session
transcripts.
Schema rules
- Active schema and authoring plans are authoritative for intent when they are ahead of Fumadocs. Read relevant
/plansfiles before touchingconvex/schema.ts. /docs/schemasis the promoted schema reference. If an active plan and schema docs diverge, update docs to follow the plan before changing code.convex/schema.tsmust match the synced plan/docs target. If sources diverge, resolve in this order: active plan → schema docs → code.- The content schema is the unified Folio model:
folios,folioVersionsandfolioPins, withlibrariesandlibraryPinsbeside them and the retiredtagsvocabulary table awaiting its drop. The three-tierdocuments/modules/blockstables and their version tables were deleted in GB-152 Stage B (2026-09-03); do not reintroduce them, nor the older supersededdocumentTemplates,documentInstances,moduleTemplates,moduleInstances,partyDataorpartyDataVersionstables. Folios Schema is the table contract. - Every application table must have a business identifier field and an index on it. For unified content tables, the identifier field is
key;authSessionskeepsauthSessionId. Convex_idis the internal row handle only — do not expose it in business logic or URLs. - Business identifiers follow the unified prefix convention:
fol_for folios minted in the app (the seeded corpora keep their publisheddoc_,docv_,mod_,modv_,blk_,blkv_keys verbatim),fld_,user_,gen_, and browser/session prefixes where documented. Retired prefixes such asDOCT-,DOCTV-,MODT-,MODTV-,MODI-, andPARTY-are historical only. _creationTimeis the canonical insert timestamp. Do not add a redundantcreatedAtfield.- Use
v.optional()for fields that may legitimately be absent. Do not usev.nullable()— Convex's type system does not have a null primitive. - Model conditional invariants in validators or mutations where Convex cannot express them directly. Example: only a block folio's version carries a
payload, and its shape followsfolios.kind— enforced byconvex/folio/writeGuards.ts, not by the table layout. - Route keys live on the addressable folio (
folios.routeKey). versionStatus(live/versioned) is one-way. Sealing aliveedition freezes that row and its pins; never mutate aversionedrow.- Progress is derived from the fields (a field is done when it holds a value or is
skipped); nothing stores a status. - Sealed rows are immutable once committed. The live head may be patched in place; advance
folioVersions.editedAton persisted edits.
Function file conventions
convex/schema.ts,convex/convex.config.ts— the data model and the installed components, pinned at the rootconvex/sessions.ts— auth session lifecycle (create, query, bind to a Document Folio) and the browser-bound session lookup the canvas route starts fromconvex/documentSessionBinding.ts— the one session-binding rule every read and write authority callsconvex/authEvents.ts— the append-only auth auditconvex/folio/— the unified card: validators, guards, the read layer (reads.tsregisters the public reads), the executors landed so far, and the seed with its reconciliation reportconvex/library/— the dormant shelf's one writerconvex/goldie/— Goldie's instruction sources and their compiled form; her tools are re-registered against Folios in GB-152 Stage Econvex/keys.ts,convex/blockRefusal.ts,convex/blockFieldSchema.ts,convex/displaySchema.ts,convex/folio/proposalSchedule.ts— key minting, the typed refusal, and the shared validators the folio concept and theproposalscolumn read; the shape validators with a domain twin are printed from the domain's Zod schemas withzodToConvexrather than written by hand (GB-172, GB-182; the ledger isconvex/derivedValidators.test.ts, which refuses a derived validator that carriesv.any()), while plain vocabularies and the authoringdisplayshape stay nativev.*per the buy-versus-build ruling
The legacy tier modules — documents.ts, modules.ts, the interview and
proposal machinery and the block agent — were deleted in GB-152 Stage B
(2026-09-03). internalRefs.ts went with the last cross-file reference and
returns, with its guard in functionPathGuard.test.ts, when one is needed.
Only add a new file when it has a clear, single responsibility not already owned by an existing file.
Concept slices, and how a file moves into one
In plain terms: the filing cabinet is organised by subject — everything about Blocks in the Block drawer — not by the kind of paper. We re-file a folder when we are already working in it, never as a separate tidying project.
The settled target is concept-sliced Convex: a folder per concept, with
schema.ts and convex.config.ts pinned at the root (and internalRefs.ts
there whenever cross-file function references exist). A concept's queries and its mutations live together under it; the
repository never collects every query in one folder and every mutation in
another. See
One Domain Rulebook, Concept-Sliced Convex, Standard FSD.
The concepts are the ones the unified schema has (owner ruling,
2026-09-01). The earlier manifest named convex/blocks/, convex/documents/
and convex/modules/ — three drawers the Folio unification abolishes, because
a document, a module and a block are now three kinds of one record, not three
tables. Slicing the backend by tier would have filed the backend against a
shape GB-152 Stage B deleted (2026-09-03).
| Concept | Holds | Note |
|---|---|---|
convex/folio/ | the card, its save points and its schedules, and the unified seed (folio/seed.ts) | The unified model's home |
convex/library/ | the shelf and its filing slips | Built, dormant: the library feature is post-MVP |
convex/interview/, convex/agents/, convex/sessions/ | unchanged concepts | Sliced when their own work earns it |
In plain terms: name the drawers after what the cabinet actually holds. We were about to label three drawers for a filing system we are in the middle of throwing out.
Current state: convex/ is sliced where it has concepts. folio/ and
library/ exist; the handful of root modules left after GB-152 Stage B
(sessions, the session-binding rule, the auth audit, the dev agent surface and
the shared validators) are flat, which is a known gap, not an oversight. The
one-off convex/folioMigration/ runner was deleted in Stage A and the legacy
tier modules in Stage B (both 2026-09-03). Inside convex/folio/, Current
is the validators, the key-bearing guards, the card and schedule writes, the
About codec, the read layer — resolution, schedule, snapshot, version history,
the recent list and the export input — and the seed. The table above names concepts,
not an inventory of files. The move discipline is a standing rule, and there is
deliberately no bulk rename:
- A file moves only when real work earns its concept folder. Do not create a concept directory for symmetry, and do not open a re-filing session.
- The move carries every path with it — generated
api.*/internal.*references, themakeFunctionReferencestrings ininternalRefs.ts, thepackage.jsonscript paths,scripts/*.mjs, and any function path named incontent/docs/. - Amend the documents first, then make the tree match them. Same commit
either way — a ruling never waits for a later sync — but first, because
writing the replacement manifest is what forces you to enumerate the
concepts and the paths before a single file moves. Done the other way round
on 2026-09-01, the folio move amended the one rule page its author was
thinking about and left four durable-doc paths,
AGENTS.md's "convex/is still flat", and the architecture decision record's own example standing; a settlement review found them, and a settlement review is a poor substitute for a sweep. Grep the fact, not the file — the same path is quoted in pages nobody would think to open. convex/functionPathGuard.test.tsmust be green through the move. It harvests every out-of-compiler path string from where it lives and resolves it against the generated API manifest, so a path left behind fails the test.bunx convex dev --onceis the full backend gate, because TypeScript alone does not cover Convex test files. Where it runs, and what a cloud session needs to run it, is Repository Operations — one home for a gate, never a second copy here.- A
convexTestharness takes its module map fromconvex-dev/convexTestModules.ts, never from an inlineimport.meta.glob. The glob resolves against the file that calls it, and a../**glob silently omits the caller's own directory — so the same incantation typed inside a concept folder returns every module except that concept's, and the harness fails with "Could not find module for: folio/writes" rather than with anything naming the glob. Anchoring the map one level above the function tree makes a test's depth stop mattering.
The archived plan 2026-08-09-codebase-architecture's
Appendices B.1 and C.3 hold the original per-concept manifest — historical
seed only, and superseded above wherever it names a tier as a concept. Verify
each row against the tree before acting on it.
Dev-only fixtures
Convex treats files under convex/ as part of the function module tree, including nested helper-looking folders. Keep non-registered development fixtures outside that tree in convex-dev/, and import them from thin registered entrypoints such as convex/folio/seed.ts.
Use this split when a file is data or helper implementation only. If the file exports a query, mutation, or action that should be callable through the Convex dashboard or generated client, it belongs in convex/.
Internal function pattern (avoiding circular imports)
Actions in file A cannot call internal.fileA.* or internal.fileB.* via the generated internal object without creating a circular type reference. The correct pattern:
- Put internal queries and mutations in dedicated helper files by responsibility (e.g.
interviewContext.ts,interviewWriteModule.ts,workingDocument.ts). - Declare the cross-file reference once in
convex/internalRefs.ts, not at the call site. - In the action file, import the named ref from
internalRefsand pass it toctx.runQuery/ctx.runMutation.
// convex/internalRefs.ts — single source of truth
import { makeFunctionReference } from 'convex/server'
export const getSessionByAuthSessionIdRef = makeFunctionReference<
'query',
{ authSessionId: string },
// biome-ignore lint/suspicious/noExplicitAny: Convex makeFunctionReference guidance requires any here.
any
>('workingDocument:getSessionByAuthSessionId')// convex/interview.ts — caller imports the ref
import { getSessionByAuthSessionIdRef } from './internalRefs'
const session = await ctx.runQuery(getSessionByAuthSessionIdRef, { authSessionId })Why one file: the path string (e.g. 'workingDocument:getSessionByAuthSessionId') and the argument type are stringly-coupled to the handler. Restating them at every call site invites silent drift when a handler moves or its args change. Keeping every cross-file ref in internalRefs.ts means a handler rename is one edit, not a grep across the directory.
Use any for the return type on makeFunctionReference — the actual shape is guaranteed by the helper's own validator. The one exception is when a wire-shape type is shared by multiple callers (e.g. LibraryDocument); co-locate the type next to its ref in internalRefs.ts so callers and ref agree.
Do not import makeFunctionReference from outside internalRefs.ts.
Agent wiring rules
- The Convex Agent component owns transcripts;
folioAgentThreadsbinds one thread to one folio and the session that opened it. Folio tables store authored content and values; nothing agent-side stores document content. - One Goldie per folio (One Goldie Per Folio):
one agent definition in
convex/goldie/agent.ts, a per-turn briefing built from the Folio read layer inconvex/goldie/briefing.ts, and tools inconvex/goldie/tools.tsthat take only the thread id — the binding says whose authority applies. No identity crosses the wire. - Writing is proposals only: the one write tool files through
goldie/threads:fileFromThreadintofolio/proposalWrites.ts; acceptance is a Builder's act. Add a read tool freely; add a write tool never — widen the admitted proposal grammar instead. - Tags are Goldie's alone, and not a chat tool: scheduled jobs in
convex/goldie/tagging.tsread a Library's guide and write each item's guide and tags through the internal door inconvex/goldie/tagWrites.ts(Goldie Tags by Instruction). No public mutation takestags; a test enforces it. - Cross-file references from the action and the tools are declared once in
convex/internalRefs.ts, per the internal function pattern above. - The brief is Markdown under
convex/goldie/instructions/, compiled bybun run generate:goldie-instructions;ASSEMBLY.mdthere is the index.
Versioning and snapshot rules
Use the Folios Schema as the single table contract:
- Documents, modules and blocks are rows in
folios, distinguished bykind. Their editions live infolioVersions; block text and fields live in its typedpayload, and container data is derived from pinned data blocks. - Rendering and export use the Folio read layer. Contents recurse through
folioPinsfromparentVersionKeytochildFolioKey/childVersionKeyinorder;parentFolioKeyrecords containment, not an edition's contents. - A sealed edition reads exactly its pinned child editions, never mutable
source heads. Library membership and derivation follow the schema's library
and lineage contracts; do not restore
reuseTypeor legacy generation tables.
Schema migrations: widen, migrate, narrow
In plain terms: don't strike a clause from the precedent while old files on the shelf still contain it. First add the new wording alongside the old (widen), update every old file to match (migrate), and only then delete the old wording (narrow). Skip a step and the registry — the Convex push — refuses every new filing until someone reconciles the records by hand.
Removing or tightening a field on an existing table always requires a
migration in the same change. "Tightening" includes: deleting a field,
making an optional field required, or narrowing a v.union case. It is not
optional and not deferrable — bunx convex dev --once is the only thing that
sees pre-existing rows that still carry the old shape, and it is the gate
every push runs. A change that is correct by tsc and the test suites can
still be one un-migrated narrowing away from locking the backend for every
other deployment holding old data. The author's own sandbox is the least
likely place to notice, because it is the least likely to hold old rows —
everyone else hits it first.
The sequence, every time a field's shape is retired:
- Widen. Re-admit the old shape alongside the new one — typically
v.optional(v.string())(or whatever the retiring type was) staying in the validator next to its replacement, so both old and new rows validate. - Migrate. Backfill every row still on the old shape to the new one.
Define it in
convex/migrations.tswithmigrations.define(the@convex-dev/migrationscomponent: resumable, runs once, reports progress) and run it against each deployment withbunx convex run migrations:run '{"fn": "migrations:<name>"}', or every pending one withmigrations:runAll. Keep the per-row rule pure inconvex/retiredColumns.tsso it is tested on plain objects.- Use
ctx.db.replace, notctx.db.patch, to drop a field.patchcannot remove a field the validator no longer declares — it can only set fields the schema still knows about.replacewrites the whole document, so it is the only way to land a row that no longer carries the retired field at all. - Prefer fixing the field on the existing row over deleting the row. The
validator error names a row (e.g. "row X has extra field
stamp"), and deleting it is the instinct the message invites — but the row is very often not an isolated unit: other rows may hold a reference to it (apreviousVersionKey, a foreign key, an audit anchor), and deleting it silently orphans them. The safe unit of work is almost always the field, not the row.
- Use
- Narrow. Once every row is confirmed migrated, remove the old shape from the validator (and the mutation, and any UI reading the old field) in a follow-up change. Only narrow after the migrate step has run against every deployment that matters — narrowing before that is exactly the fault this rule exists to prevent.
A cloud sandbox cannot verify this rule by itself. tsc and the test
suites cannot see a validator/data mismatch — the only witness is real
pre-existing data meeting the new validator, which requires a deployment push
that a cloud sandbox may not have. bunx convex codegen is not that push: it
uploads functions to regenerate bindings without validating stored rows
against the schema, so a codegen that succeeds proves nothing about the data
(learned 2026-09-15, GB-248: three legacy proposals rows blocked every push
on the dev deployment after a bare narrowing). Where a change touches an existing table's
field shapes, treat this section as the check standing in for that push: read
it, and if a change deletes or narrows a field without a migration in the
same change, that is the defect to fix before treating the change as
gate-clean — deployment-verified or not. Silence on this point is the defect
this section exists to close.
Query and mutation scope
- Use
queryandmutationfor functions called directly from the frontend. - Use
internalQueryandinternalMutationfor functions called only from actions inconvex/files. - Actions (
action) are for any work that calls external APIs (OpenAI, auth providers) or orchestrates multiple mutations. - Do not put business logic directly in a
mutationhandler that should be composed into anaction.
Agent docs scope
Goldie is built on the Convex Agent component (@convex-dev/agent, pinned in
package.json; 0.6.4 at the time of writing). The component's documentation is
referenced, never copied into this repository: a saved copy goes stale the
day the pin moves and adds rule mass to every task, while the public site
describes the latest release, which may be newer than the pin. Read it this way:
- Pin first. Read the installed version from
package.json(ornode_modules/@convex-dev/agent/package.json) before reading any page. - Fetch the page for the trigger through the vendored
convex-docsskill, which prefers the markdown form of a page and checks the installed types when a page and the pin disagree. The installed.d.tsis the ground truth for this version; the page is the explanation. - Keep local facts local. The session-to-thread link, the Folio boundary and what the component owns are this repository's decisions and live in Agent wiring rules above, not in the Convex docs.
| Work touches | Page |
|---|---|
| Agent orchestration, the component's shape | Agents overview |
| Thread or session state | Threads |
| Tool calling | Tools |
| Agent memory, what a run can see | Context and Messages |
| Streaming responses | Streaming |
| Background or async workflows around agent runs | Workflows |
When adding or changing agent behaviour, prefer the documented Convex Agents
patterns over ad hoc local abstractions unless there is a clear product-specific
reason to diverge, recorded in the task. When the pin moves, run
npx convex ai-files update on a branch so the vendored skills and generated
guidelines move with it; the generated-binding gate reports exactly what changed.
In plain terms: we keep the phone number of the current edition of the textbook, and check which edition we own before we ring it, rather than photocopying a chapter that will be out of date by next term.