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
Decision
A refused block edit crosses the wire as structured data, and every block-scoped message lands in that block's notification row.
Two CodeModules own the channel, one per side of the wire:
| CodeModule | Owns |
|---|---|
convex/blockRefusal.ts | refuse() — what a domain rule throws when it says no. Companion to whichever write envelope the rule runs inside; today that is the Folio operation prologue. |
src/entities/block/lib/block-notification.ts | toBlockNotification() / confirmBlockAction() — classifying a rejection, and constructing a success message, for the side panel's notice row (NoticeRow, in shared/ui/molecules/panel-notice/v1 since 2026-09-28). |
And, separately and permanently: the agent's in-band writeModule
outcomes are a different channel with a different audience, and the two are
never merged. That rule is the most load-bearing part of this record.
Context
The round trip that destroyed structure
packages/domain/src/block/block-facet-authoring.ts already models a refusal as
a first-class
result:
export type FacetParseResult<T> =
| { ok: true; value: T }
| { ok: false; message: string; line?: number };(line is 1-based and optional — not every failure is positional. It exists
so a parse refusal can point at a region of the About document, where "which
line" is otherwise ambiguous.)
That structure was then thrown away and guessed back, in four steps:
- The parser returns
{ ok: false, message }. - The mutation (then
convex/documents.ts, since deleted) doesif (!parsed.ok) throw new Error(parsed.message)— flattening the discriminated union into a string. - Convex serialises the throw, wrapping it in transport framing
(
Uncaught Error: … at handler (…)). - The editor regex-strips the framing to recover the original
message.
Step 4 lived in a module-private, unexported, untested function holding two unexplained regexes. Nothing server-side asserted the framing they stripped — the regexes were the only place in the repo that knew the format, and they were the primary path, running on every single refusal.
The fix is to stop destroying the structure, not to name the reconstruction.
Refusals and faults are different populations
The block-editing mutations threw bare Error for two unrelated things:
| Meaning | The user can | Example | |
|---|---|---|---|
| Refusal | A rule said no | act on it | Handle "x" is already used in this module. |
| Fault | An invariant broke | do nothing | Block row is missing |
Both took the identical path and both rendered as amber refusal prose in the notification row. The row was lying about faults — presenting a broken invariant as though the user had tripped a rule they could correct.
The server contract
// convex/blockRefusal.ts
export type BlockRefusalKind =
| "facet_source" // parse rejected the edited source
| "identifier_taken" // handle collision
| "not_permitted" // wrong block kind, wrong scope for this edit
| "empty_value"; // a required value was blank
export type BlockRefusalData = {
refusal: true;
kind: BlockRefusalKind;
message: string; // user-facing, already lawyer-readable
facet?: "content" | "about" | "fields";
fieldKey?: string; // the fld_ identity of the one field a rule refused (GB-181)
line?: number; // 1-based, within the About document
};
export function refuse(data: Omit<BlockRefusalData, "refusal">): never;refuse() throws new ConvexError({ refusal: true, ...data }). The
refusal: true marker is the discriminant — the client tests for it
structurally and never parses prose to decide what kind of failure it has.
A refusal names the field it refused (GB-181, 2026-09-05): the field
executors' one gate (convex/folio/fieldWrites.ts) puts the refused field's
stable key on fieldKey, so the data block's row can show the words beside
the field rather than the reader searching the card. It is the same channel
with one more member; there is no second refusal state, and a second refusal
replaces the first (decision-8's open question is answered by the channel's
existing behaviour until the owner rules otherwise).
This is the codebase's first use of ConvexError. There was no migration
debt to weigh: every prior throw was bare.
Which sites convert
Only rule-refusals reachable from block-editing mutations — roughly eight of the thirty-four throws:
| Site | Becomes |
|---|---|
documents.ts updateBlockAbout if (!parsed.ok) | facet_source, carrying parsed.message and, where the failure is positional, parsed.line |
documents.ts handle collision | identifier_taken |
documents.ts Label cannot be empty | empty_value |
documents.ts "only data-block fields" / "only content blocks" / "fields only on data blocks" | not_permitted |
documents.ts field-type transition.error.message | not_permitted |
documents.ts updateBlockAbout flow-condition and content compile failures | facet_source |
The last row was missed by the original sweep and converted 2026-08-08. It is
the same failure class as the parse row above it, one step later in the same
handler: a ReferenceFailure names a reference the author just typed, not a
broken invariant. updateBlockContent had refused the identical class for
the Content facet since the sweep, so the two halves of one rule had
diverged — with the About half silently rendering as fault copy, which is the
exact confusion this decision exists to prevent, running in the other
direction.
The facet_source conversion is the round trip closing:
FacetParseResult's message now reaches the row as structure, never as a
string that has to be re-parsed.
Which sites deliberately stay bare throws
Converting these would let the row claim a broken invariant is an actionable refusal:
- All five in the retired
convex/blockEdit.ts—Document not found,Module version is not the document's live version,Block not found,Block handle is not unique,Document is missing its live version. Every one is a broken assumption, not a rule the user tripped. documents.tsBlock row is missing,Not authorised to modify this document, and the dev-sync throws.- Every throw behind
api.interview.sendMessage. These reach the block panel via the agent prompt path, and are all faults — so that path correctly receives fixed fault copy rather than leaking a stack trace.
The client Interface
// src/lib/block-notification.ts
/** What the notification row renders, matched to one block. */
export type BlockNotification = {
moduleId: string;
blockId: string;
message: string;
tone: "confirmation" | "refusal";
/** Omitted for refusals — they stay until dismissed. */
autoDismissMs?: number;
};
export function toBlockNotification(
error: unknown,
target: { moduleId: string; blockId: string },
): BlockNotification;
export function confirmBlockAction(
message: string,
target: { moduleId: string; blockId: string },
): BlockNotification;Invariants a caller must know:
toBlockNotificationnever throws and never returnsundefined. Every rejection produces a renderable notification — there is no path where an edit fails silently.- Its
toneis alwaysrefusalandautoDismissMsis always absent. Refusals persist until dismissed, per the row's documented lifetime rule; a refusal that vanishes leaves you wondering. confirmBlockActionalways setstone: "confirmation"and a non-zeroautoDismissMs. The result is already visible in the box above, so the words are a receipt, not a standing fact.- One notification at a time, editor-wide. A new one on any block replaces the previous one on any other; success clears it. It is not a per-block map.
- Faults never leak prose. With no
refusal: truemarker, the message is fixed copy and the original is logged. A stack trace never reaches a lawyer.
Three tiers, and where the regexes went
toBlockNotification classifies in order:
ConvexErrorcarryingrefusal: true→ usedata.messageverbatim. The clean path.- Any other error → generic fault copy, original logged.
- Fallback only: a
ConvexErrorwhosedataarrived as a string rather than an object — the production-stringification case. Only here do the two transport-framing regexes run.
The regexes survive, but demoted: from the path every refusal took, to a case that should not occur. They move into this CodeModule with a comment naming the framing they strip, and — for the first time — a test.
Two channels, two audiences, never merged
This is the rule most at risk of being quietly undone by the next build.
BlockRefusal | writeModule outcome | |
|---|---|---|
| Shape | thrown ConvexError | returned in-band on the result |
| Audience | the person | Goldie |
| Lands | the block's notification row | never renders |
The outcome messages carry retry instructions written for an agent —
Call getInterviewContext({ selection }) and use a handle from modules[].handle. They are returned rather than thrown specifically so the
agent's turn survives and it can correct itself; throwing would abort the
turn. Put in front of a lawyer, they are noise at best.
So:
- Adding a new agent outcome must not route it to the notification row.
- Surfacing a refusal to the user must not return it in-band.
A dev-only chip status dot is excluded from this CodeModule. Mapping an
agent outcome to a status dot is telemetry about a tool call, not a message to
anyone, and folding it in would couple the lawyer-facing channel to a debug
affordance. (The mapper this rule was written against, deriveToolState, has
since been removed; the exclusion governs its replacement.)
Where a message goes: the row or a toast
Block-scoped edits speak in the block's row. Document-scoped actions toast, because they navigate.
Block-scoped, all moving to the row: field changes (confirmation on success, refusal on reject), content changes (row on failure; success stays silent — it is visible in place), facet changes, adding a field, adding a data block. The two additions keep their return contracts unchanged; only where the words land changes, and the row can name the new handle just as well as a toast could.
Document-scoped, staying on toasts: rename, duplicate, delete. Duplicate and delete navigate, so the row would scroll away or unmount underneath the message — and delete owns an Undo action the row has no affordance for.
Why not merge the two error channels
They differ by audience, not by mechanism. A single channel would either put agent retry instructions in front of the user, or strip the instructions the agent needs to correct itself. The apparent duplication is two correct answers to two different questions.
Why not convert all thirty-four throws
The population that matters is the one the notification row renders. Faults must stay structurally distinguishable from refusals, and the cheapest way to guarantee that is for faults to keep the shape they already have — a bare throw carries no marker, so it cannot be mistaken for a rule. Converting everything would erase exactly the distinction the discriminant exists to draw.
Why not delete the regexes outright
Convex stringifies unhandled bare errors in production regardless of what the
application throws. A ConvexError whose data arrives as a string is
therefore possible even after this change. Deleting the fallback would trade
an untested path for a missing one.
Why not a port and adapter
Neither CodeModule does I/O — refuse() constructs and throws,
toBlockNotification classifies a value. Both are in-process. The Convex
mutation sits outside both, not behind an injected seam. A port here would
have exactly one adapter, which is indirection, not a seam.
Tests sit at each interface: that refuse() produces a ConvexError with
the marker; that a real refuse() round-trips back to the same message; that
a bare Error never leaks its message; and that the stringified fallback
tier strips the framing. The round-trip test is the one that earns the
design — it asserts end to end that a parser's message reaches the row
unchanged.
Revisit when
- A refusal needs to name a field within a block, not just the block — the
target shape would grow beyond
{ moduleId, blockId }. - More than one notification needs to be live at once (two blocks refusing concurrently), which would retire the one-at-a-time invariant.
- An agent outcome genuinely needs to reach the user — at which point it
should be translated into a
BlockRefusalat a named site, not routed across the channels.
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
Builder First, Before Sharing
Why the first product loop proves one Builder making a versioned Document, and what is deliberately outside that proof