Goldie Blocks

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:

CodeModuleOwns
convex/blockRefusal.tsrefuse() — 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.tstoBlockNotification() / 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:

  1. The parser returns { ok: false, message }.
  2. The mutation (then convex/documents.ts, since deleted) does if (!parsed.ok) throw new Error(parsed.message) — flattening the discriminated union into a string.
  3. Convex serialises the throw, wrapping it in transport framing (Uncaught Error: … at handler (…)).
  4. 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:

MeaningThe user canExample
RefusalA rule said noact on itHandle "x" is already used in this module.
FaultAn invariant brokedo nothingBlock 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:

SiteBecomes
documents.ts updateBlockAbout if (!parsed.ok)facet_source, carrying parsed.message and, where the failure is positional, parsed.line
documents.ts handle collisionidentifier_taken
documents.ts Label cannot be emptyempty_value
documents.ts "only data-block fields" / "only content blocks" / "fields only on data blocks"not_permitted
documents.ts field-type transition.error.messagenot_permitted
documents.ts updateBlockAbout flow-condition and content compile failuresfacet_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.ts Block 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:

  • toBlockNotification never throws and never returns undefined. Every rejection produces a renderable notification — there is no path where an edit fails silently.
  • Its tone is always refusal and autoDismissMs is always absent. Refusals persist until dismissed, per the row's documented lifetime rule; a refusal that vanishes leaves you wondering.
  • confirmBlockAction always sets tone: "confirmation" and a non-zero autoDismissMs. 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: true marker, 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:

  1. ConvexError carrying refusal: true → use data.message verbatim. The clean path.
  2. Any other error → generic fault copy, original logged.
  3. Fallback only: a ConvexError whose data arrived 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.

BlockRefusalwriteModule outcome
Shapethrown ConvexErrorreturned in-band on the result
Audiencethe personGoldie
Landsthe block's notification rownever 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 BlockRefusal at a named site, not routed across the channels.

On this page