Goldie Blocks

Block Edits Run Inside One Envelope

Why the seven block-editing mutations share a single bracketing seam that owns load, guard and stamp, and why the after-resolution rule is extracted as a pure function

The `flow.after` half is superseded

Dated 2026-07-29. The envelope decision stands. The ordering mechanism it describes does not: stored order is now order.position, a gapped integer on the row, and blocks are read by sorting on it rather than by resolving an anchor. resolveFlowAfter and the handle→key backfill were deleted with the anchor machinery. See Blocks → Block Order and Flow → Ordering. Read the after passages below as the record of what was true then.

Decision

Every block-editing mutation runs its edit inside withBlockEdit — one envelope that owns the prologue and the epilogue. The mutation supplies only its own domain rule.

The envelope brackets the edit. It loads the owned document, guards the head version, resolves the live module version, reads the blocks its caller asked for, matches exactly one target block, and — after the caller's rule returns — stamps editedAt on both the target blockVersions row and the owning moduleVersions row. No caller can forget the epilogue, because no caller writes it.

Current implementation

The caller supplies only a requested exact operation or one recorded capability-only policy alongside its Block scope and domain rule. withBlockEdit constructs lock, payload, parent, relationship, and other trusted Block facts from the resolved rows, invokes convex/folio/writePolicy.ts before the rule, and retains its Block-specific persistence epilogue.

Agent writes over a live assembled Block use the same envelope. Detached Block Library promotion is a separate Block operation, while inserting a Library Block enforces policy against the parent Module before copying the child. Those different transactions consume the same policy rather than pretending to be configuration variants of this envelope.

Alongside it, resolveFlowAfter is extracted as a pure function and becomes the single answer to "what does flow.after store". Every writer calls it. It resolves an authored name — handle or key — to the sibling's stable blockKey, per References Bind To Stable IDs.

The Interface

withBlockEdit(ctx, args, scope, policy, rule) => Promise<Result>

Arguments the caller must supply

PartMeaning
args{ authSessionId, documentKey, moduleVersionKey } plus blockHandle when the scope names a target.
scopeWhich read the caller needs. One of target, module, or module-fields.
policyThe exact Folio operation or recorded capability-only gap; never trusted facts.
ruleThe caller's domain rule. Receives the view; returns the patch.

Scope is the honest form of the two-prologues split. Five mutations need the whole module's ordered blocks; two need only their target. Forcing the narrow two to load the whole module would trade duplication for waste — which is exactly why they hand-rolled their lookup in the first place. Scope makes the difference part of the Interface rather than an accident.

scopeReadsCallers
targetThe one matched block only.updateBlockField
moduleAll pinned blocks, ordered by order.position, via readPinnedBlocks.updateBlockContent, updateBlockAbout, Block naming and Agent proposal acceptance
module-fieldsThe matched block, plus every data block's field handles.addBlockField

module-fields is not module plus extra. addBlockField needs the set of taken field handles across the module — a different question from ordering, and one that does not need the fold. Naming it separately keeps the fold off a path that has no use for it.

The view handed to the rule

FieldPresent when
documentAlways. Owned, with a non-null headVersionKey.
moduleVersionAlways. Live, and belonging to the document's head version.
blockAlways. Exactly one match, merged with its parent blocks row, so handle, label and blockKey are all in scope.
pinnedscope: "module". Ordered. Includes the target.
takenFieldHandlesscope: "module-fields".

addDataBlock is the one caller with no target block: it passes the scope module and no blockHandle, and its view carries no block. This is why the envelope's match step is driven by the presence of blockHandle rather than by scope alone.

What the rule returns

The rule returns the patch to apply and the mutation's return value. It does not call ctx.db.patch on the target block version or the module version, and it does not compute editedAt — the envelope owns both. A rule that must write rows the envelope does not own (addDataBlock inserts blocks and blockVersions; updateBlockAbout patches the parent blocks row and sweeps siblings) does so through ctx directly, and the envelope still stamps the module version afterwards.

updateBlockAbout is where the envelope's transaction earns the most. The About rows parses one YAML document and patches blocks.label, blocks.handle, blockVersions.content, .guide, .display and .flow from it. All of that happens inside a single withBlockEdit, so a multi-table declaration edit is atomic — it lands whole or not at all. It replaces the three separate updateBlockLabel / updateBlockDisplay / updateBlockFlow mutations this record originally listed, which could not offer that guarantee across a save the user experienced as one.

The mid-transaction compile. updateBlockContent and updateBlockFlow build the document field catalog between resolve and write. This is the main risk in choosing an envelope over a resolver, and the accommodation is deliberate: the rule is an async callback running inside the envelope's transaction, not a pure reducer. It holds ctx and may read whatever it needs — including buildDocumentFieldCatalog — before returning its patch. A compile failure throws from inside the rule, the transaction rolls back, and nothing is stamped. The envelope is bracketing, not sequencing: it does not force those two callers back out to a resolver, and it does not pull the catalog into the prologue where five callers would pay for a read they never use.

Error modes, in the order the envelope raises them, all as thrown Error:

  1. Document not found or not owned by createdBy.
  2. Document is missing its live version — no headVersionKey.
  3. Module version is not the document's live version — missing, wrong document version, or not live.
  4. Block not found: {handle} — zero matches.
  5. Block handle is not unique: {handle} — more than one match.

Today the uniqueness failure is thrown three incompatible ways across the seven — two separate throws, a ternary in one throw, and addDataBlock which never checks uniqueness at all and only tests membership. The envelope collapses these to one.

resolveFlowAfter

resolveFlowAfter(authoredAfter, siblings, selfKey) => string | null

Pure. Given an authored name and the module's blocks, returns the sibling's blockKey, or null for no predecessor. Throws when the name matches no sibling, and when a block names itself. It resolves either form of the authored name — handle or key — because orderBlocksByFlowAfter accepts both during the transition and authors type handles.

This extraction is the substantive reason this candidate covers the after-rule rather than the prologue alone. Two writers currently mint the legacy handle form:

  • addDataBlock (convex/documents.ts:1358-1360) stores args.afterBlockHandle ?? ordered[last].handle. It projects through sortModulesByFlow in a shape that drops blockKey entirely, so the stable key is not even in scope at the write.
  • blockReuse.ts:110-112 computes flowAfter from destinationHandles and returns a handle.

Only updateBlockFlow (documents.ts:1130) resolves to the key. Routing all three through resolveFlowAfter fixes the writers as a side effect of the extraction — which is the point.

Context

convex/documents.ts — since deleted with the legacy tables — was 1,649 lines, and seven mutations in it restated the same opening: load owned document, guard headVersionKey, resolve the live module version, read the module's pinned blocks, match exactly one block by handle. Eighteen to twenty-four lines each. updateBlockDisplay is 45 lines of which about 7 are behaviour; its prologue alone is 28.

Repetition alone would be tolerable. The drift is not:

  • updateBlockField (:712-727) and addBlockField (:1201-1222) hand-roll the block lookup and bypass readPinnedBlocks. That leaves the lookup rule with two Implementations, and e06e9c5 updated only one.
  • loadLiveModuleVersion (:758) exists, and three callers still carry byte-identical inline copies (:700-710, :1189-1199, :1326-1336).
  • The uniqueness error has three shapes, one of which does not check uniqueness.

Applying the deletion test to the envelope: delete it and the complexity does not vanish, it reappears seven times — which is the state we are in now, and the drift is the evidence. It earns its keep.

The Interface is the test surface, and here that is the sharp constraint. The rules written inside these mutations — after-must-name-a-sibling, handle-collision refusal, one-block-match — are unreachable by this repo's test style, because the repo tests pure folds and nothing else. All eight convex/*.test.ts files do exactly that, and no test anywhere names any of the seven mutations.

Why not convex-test — superseded 2026-08-08

The gap is closed

convex-test arrived on 2026-08-08 for an unrelated reason — proving that a refusal crosses the wire as structure and a fault does not (notification channel), which no pure function can observe. With the harness already a dependency and wired into bun run test, the cost this section weighed no longer exists, so the guards were tested rather than left accepted. Both the envelope and its guard tests were deleted with the legacy tables; the reasoning is what this record keeps.

The original reasoning, and where it was right and wrong:

convex-test was not a dependency, and this decision did not add it. The rules extract as pure functions and those functions are the test surface. Adding a transactional harness to reach the residue would buy coverage of ctx.db plumbing whose behaviour is already fully described by its inputs.

Known gap, accepted deliberately: the ownership guard and the live-version guard stay untested. They are the two steps of the envelope that cannot be expressed as a pure function, and after this change they exist in exactly one place rather than seven — which lowers the cost of the gap without closing it. If a third guard joins them, or if one of these two grows a rule rather than a lookup, revisit convex-test then.

The cost half of that reasoning was sound and simply expired. The value half — "plumbing whose behaviour is already fully described by its inputs" — was the weaker claim, and it is worth naming why, so the same argument is not reached for again. The ownership guard is authorisation. The risk was never that its lookup computes the wrong answer today; it is that a later edit drops document.createdBy !== createdBy and nothing says so. That is a regression a test catches and a pure-function surface cannot, because the guard's whole job is to run before the pure part.

Two properties the tests pin that were not obvious from the code:

  • A refused edit writes nothing. Each guard test asserts the block version's content is unchanged, not merely that a throw occurred. A guard that raised after a patch landed would still throw.
  • The ownership failure is not an existence oracle. A missing document and a document owned by someone else raise the same message, so the mutation cannot be used to probe for document keys. The natural-looking improvement — a clearer Not authorised for the owned case — would be a regression, and is now pinned against.

Why not a resolver returning a context struct

The first shape considered was resolvePinnedBlockEdit(ctx, args) returning { document, moduleVersion, pinned, target }. It fails the depth test: it is a struct-returner, its Interface is nearly as large as its Implementation, and every caller still writes its own epilogue. The two editedAt patches would remain duplicated seven times, and the drift class this decision exists to close — one site updated, six missed — would stay open at the other end of the mutation. The envelope owns both ends, so forgetting the epilogue is not expressible.

Deferred work, in this order

The rename sweep in updateBlockLabel (:998-1015) survives this change untouched. It no longer affects ordering — orderBlocksByFlowAfter (convex/blockFold.ts:244) emits each block under every name it answers to, so a sibling pointing at either the handle or the key sees the same predecessor satisfied. The sweep is now a lazy migration, not a correctness rule, and it should eventually be replaced by a one-shot backfill on the backfillFieldIdentities (:1253) precedent.

It cannot be deleted yet, and the order matters:

  1. Fix the writers. addDataBlock and blockReuse.flowAfter call resolveFlowAfter and store blockKey. (This decision.)
  2. Backfill existing flow.after handle pointers to keys, one shot. (Deferred.) A backfill run before step 1 never converges — the writers keep minting the form it is removing.
  3. Delete the sweep, and amend References Bind To Stable IDs, whose "What this supersedes" section cites it as live evidence of the old model. (Deferred.)

Doing 3 before 2 would be safe but wrong in a quieter way: the fold's tolerance of handle pointers is a transition affordance, and deleting the sweep without backfilling makes that tolerance permanent.

Not resolved here

Handle collision has three incompatible answers and this decision does not pick one. updateBlockLabel (:966-978) refuses a colliding rename; blockReuse.ts:102-105 and addDataBlock (:1346-1351) both suffix _2. The refusal is a deliberate rule — "an identifier nobody chose is one nobody can read" — and the suffixers are minting exactly that. Routing them through the envelope does not reconcile them, because they disagree about behaviour rather than about structure. That is a product decision and belongs in its own record.

On this page