Goldie Blocks
Coding Rules

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 /plans files before touching convex/schema.ts.
  • /docs/schemas is the promoted schema reference. If an active plan and schema docs diverge, update docs to follow the plan before changing code.
  • convex/schema.ts must 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, folioVersions and folioPins, with libraries and libraryPins beside them and the retired tags vocabulary table awaiting its drop. The three-tier documents / modules / blocks tables and their version tables were deleted in GB-152 Stage B (2026-09-03); do not reintroduce them, nor the older superseded documentTemplates, documentInstances, moduleTemplates, moduleInstances, partyData or partyDataVersions tables. 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; authSessions keeps authSessionId. Convex _id is 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 published doc_, docv_, mod_, modv_, blk_, blkv_ keys verbatim), fld_, user_, gen_, and browser/session prefixes where documented. Retired prefixes such as DOCT-, DOCTV-, MODT-, MODTV-, MODI-, and PARTY- are historical only.
  • _creationTime is the canonical insert timestamp. Do not add a redundant createdAt field.
  • Use v.optional() for fields that may legitimately be absent. Do not use v.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 follows folios.kind — enforced by convex/folio/writeGuards.ts, not by the table layout.
  • Route keys live on the addressable folio (folios.routeKey).
  • versionStatus (live / versioned) is one-way. Sealing a live edition freezes that row and its pins; never mutate a versioned row.
  • 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.editedAt on persisted edits.

Function file conventions

  • convex/schema.ts, convex/convex.config.ts — the data model and the installed components, pinned at the root
  • convex/sessions.ts — auth session lifecycle (create, query, bind to a Document Folio) and the browser-bound session lookup the canvas route starts from
  • convex/documentSessionBinding.ts — the one session-binding rule every read and write authority calls
  • convex/authEvents.ts — the append-only auth audit
  • convex/folio/ — the unified card: validators, guards, the read layer (reads.ts registers the public reads), the executors landed so far, and the seed with its reconciliation report
  • convex/library/ — the dormant shelf's one writer
  • convex/goldie/ — Goldie's instruction sources and their compiled form; her tools are re-registered against Folios in GB-152 Stage E
  • convex/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 the proposals column read; the shape validators with a domain twin are printed from the domain's Zod schemas with zodToConvex rather than written by hand (GB-172, GB-182; the ledger is convex/derivedValidators.test.ts, which refuses a derived validator that carries v.any()), while plain vocabularies and the authoring display shape stay native v.* 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).

ConceptHoldsNote
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 slipsBuilt, dormant: the library feature is post-MVP
convex/interview/, convex/agents/, convex/sessions/unchanged conceptsSliced 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:

  1. 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.
  2. The move carries every path with it — generated api.* / internal.* references, the makeFunctionReference strings in internalRefs.ts, the package.json script paths, scripts/*.mjs, and any function path named in content/docs/.
  3. 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.
  4. convex/functionPathGuard.test.ts must 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.
  5. bunx convex dev --once is 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.
  6. A convexTest harness takes its module map from convex-dev/convexTestModules.ts, never from an inline import.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:

  1. Put internal queries and mutations in dedicated helper files by responsibility (e.g. interviewContext.ts, interviewWriteModule.ts, workingDocument.ts).
  2. Declare the cross-file reference once in convex/internalRefs.ts, not at the call site.
  3. In the action file, import the named ref from internalRefs and pass it to ctx.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; folioAgentThreads binds 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 in convex/goldie/briefing.ts, and tools in convex/goldie/tools.ts that 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:fileFromThread into folio/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.ts read a Library's guide and write each item's guide and tags through the internal door in convex/goldie/tagWrites.ts (Goldie Tags by Instruction). No public mutation takes tags; 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 by bun run generate:goldie-instructions; ASSEMBLY.md there 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 by kind. Their editions live in folioVersions; block text and fields live in its typed payload, and container data is derived from pinned data blocks.
  • Rendering and export use the Folio read layer. Contents recurse through folioPins from parentVersionKey to childFolioKey / childVersionKey in order; parentFolioKey records 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 reuseType or 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:

  1. 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.
  2. Migrate. Backfill every row still on the old shape to the new one. Define it in convex/migrations.ts with migrations.define (the @convex-dev/migrations component: resumable, runs once, reports progress) and run it against each deployment with bunx convex run migrations:run '{"fn": "migrations:<name>"}', or every pending one with migrations:runAll. Keep the per-row rule pure in convex/retiredColumns.ts so it is tested on plain objects.
    • Use ctx.db.replace, not ctx.db.patch, to drop a field. patch cannot remove a field the validator no longer declares — it can only set fields the schema still knows about. replace writes 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 (a previousVersionKey, 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.
  3. 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 query and mutation for functions called directly from the frontend.
  • Use internalQuery and internalMutation for functions called only from actions in convex/ 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 mutation handler that should be composed into an action.

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:

  1. Pin first. Read the installed version from package.json (or node_modules/@convex-dev/agent/package.json) before reading any page.
  2. Fetch the page for the trigger through the vendored convex-docs skill, which prefers the markdown form of a page and checks the installed types when a page and the pin disagree. The installed .d.ts is the ground truth for this version; the page is the explanation.
  3. 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 touchesPage
Agent orchestration, the component's shapeAgents overview
Thread or session stateThreads
Tool callingTools
Agent memory, what a run can seeContext and Messages
Streaming responsesStreaming
Background or async workflows around agent runsWorkflows

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.

On this page