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
| Part | Meaning |
|---|---|
args | { authSessionId, documentKey, moduleVersionKey } plus blockHandle when the scope names a target. |
scope | Which read the caller needs. One of target, module, or module-fields. |
policy | The exact Folio operation or recorded capability-only gap; never trusted facts. |
rule | The 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.
scope | Reads | Callers |
|---|---|---|
target | The one matched block only. | updateBlockField |
module | All pinned blocks, ordered by order.position, via readPinnedBlocks. | updateBlockContent, updateBlockAbout, Block naming and Agent proposal acceptance |
module-fields | The 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
| Field | Present when |
|---|---|
document | Always. Owned, with a non-null headVersionKey. |
moduleVersion | Always. Live, and belonging to the document's head version. |
block | Always. Exactly one match, merged with its parent blocks row, so handle, label and blockKey are all in scope. |
pinned | scope: "module". Ordered. Includes the target. |
takenFieldHandles | scope: "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:
- Document not found or not owned by
createdBy. Document is missing its live version— noheadVersionKey.Module version is not the document's live version— missing, wrong document version, or notlive.Block not found: {handle}— zero matches.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 | nullPure. 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) storesargs.afterBlockHandle ?? ordered[last].handle. It projects throughsortModulesByFlowin a shape that dropsblockKeyentirely, so the stable key is not even in scope at the write.blockReuse.ts:110-112computesflowAfterfromdestinationHandlesand 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) andaddBlockField(:1201-1222) hand-roll the block lookup and bypassreadPinnedBlocks. That leaves the lookup rule with two Implementations, ande06e9c5updated 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 authorisedfor 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:
- Fix the writers.
addDataBlockandblockReuse.flowAftercallresolveFlowAfterand storeblockKey. (This decision.) - Backfill existing
flow.afterhandle pointers to keys, one shot. (Deferred.) A backfill run before step 1 never converges — the writers keep minting the form it is removing. - 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.
Add Opens in Goldie's Panel
Owner rulings 2026-10-09 — Add and Replace file proposals that wait for the card's tick. Add Document (and every Add) no longer opens a modal. It takes over Goldie's right-hand panel for a moment, as the ⓘ About view does: a search with the All + filter control, results beneath with ticks, Cancel and Add at the foot, and Back to Goldie returning to the conversation where it was. A ticked document previews on the page as a pending card. Replaces the Add modal and its preview half; the search's scope and filter follow library-use-in.
One Block Notification Channel, Two Error Audiences
Why a refused block edit crosses the wire as structured ConvexError data rather than a stringified throw, and why the agent's in-band writeModule outcomes are never merged into the same channel