Goldie Blocks

Folio Operations Resolve Once

Why Folio action availability is resolved once in the domain, then projected to menus, agent instructions, and server enforcement

Amended 2026-09-11. The per-folio lock is retired by Maker Fills, Builder Builds: there is no write-lock row, no locked fact in the resolver and no access.locked_for writer. Every passage below that describes the lock is history. The ruling this page records — availability resolves once, in the domain — is unchanged, and the capability line is now the whole access rule.

Decision

Folio operation availability resolves once in the runtime-free domain package. packages/domain/src/folio/operation-resolution.ts owns resolveFolioOperations, which returns the available operations from static operation metadata plus the current Folio and relationship facts. A resolved operation states its kind, family, canonical label, menu group/order, capability, destructive posture, and availability. It carries no React callbacks, icons, prompts, or persistence code.

The same resolution is projected to action menus and Block-agent permission/instruction selection. One resolved array supplies a Block-agent turn's prompt, schema, and fold. Proposed Change acceptance independently reconstructs trusted server facts and invokes the same resolver before its facet-specific validation and patch. A surface binds an executable handler and its local disabled explanation; a kind/facet adapter supplies source grammar and validation. Neither consumer may restate which operation is allowed.

The initial Interface is exhaustive for implemented Folios only: Document, Module, and Block. Project joins by adding its adapter after its schema defines real invariants; it is not a speculative union member or empty menu state.

Server executors reconstruct their facts and require the same exact resolved operation before writing. Document, Module, Block, Library, and Agent mutations retain their own validation, identity, lineage, ordering, and patch rules.

Owner-settled menu, 2026-09-08: the shared Folio menu uses labelled shadcn groups, with empty groups omitted. Catalogue group/order remains the single ordering authority; permissions and handler binding remain unchanged.

  • TRANSFORM: Level, then Change numbering. Level retains Title, Section, Clause and Sub-clause. Each row prefixes its name with the level number (1 Title, 2 Section, 3 Clause, 4 Sub-clause); only the active row shows a trailing tick (owner, 2026-09-11). The parent trigger says only Level, with no current-value readout beneath it. Change numbering keeps the existing item-selection controls; it is never a global On/Off switch.
  • ARRANGE: Add (Content Block, Data Block, Module), Move (Up, Down), Delete. Duplicate is omitted from the MVP menu; the existing operation and binding remain available for future use. Owner ruling 2026-09-09: Delete is a plain destructive row and there is no "Remove from module/document" row (see Delete Is a Folio Fact); the remove operation stays in the catalogue for the Library. Deletion remains the recoverable Folio lifecycle operation, never a permanent purge. Entries appear only where applicable and wired.
  • ACCESS: retain the existing Maker-editing lock action and state. Moving Lock to post-MVP must not remove working access controls.
  • Block/Module footer: Version, then About. Version contains Save Version and history. About opens the existing facet. Other already-bound operations retain their catalogue group.

Start on new page and a Paragraph level are deferred: this is a reorganisation of existing functionality, not a change to block display semantics. The future taxonomy reserves ACCESS for Hide (Builder/Maker/Reader) and richer Lock controls; LIBRARY for Sanitise for Library and Add to Library (My Library/Firm Library/other libraries); ADVANCED for Condition and Adapt. These future controls do not appear as placeholders.

GB-204 replaces separator-only groups and flat arrangement/version rows with this shared presentation. The About entry retains the Builder-only facet gate. Section labels render at 10px, smaller than action rows. Menu actions are text-only; submenu arrows and current-selection ticks remain. GB-157 continues to own unbound numbering controls in the live canvas; this menu update preserves existing bindings. The Document menu retains its existing leading About entry; moving that entry is outside this Block-menu update.

Target: Numbering flyout — GB-157

Owner-settled 2026-09-08: replace the Change numbering action with Numbering ›. Its checkbox rows are Headings, Paragraphs and List items, followed by a separator and Restart numbering. A checkmark on the right means enabled. These map to the current Folio payload's headings, paragraphs, lists and restart booleans. Level remains separate.

Current: labelled groups, compact arrangement/version flyouts and the existing numbering callback are built. Gap: the checkbox flyout and its canvas persistence wiring are not built. The Numbering Storybook spec previews local state only; it is not evidence of saved numbering or rendered output. Promote it to Live only when the app integration is implemented and verified.

Export — GB-222, revised 2026-09-11

The current Export interaction is governed by Document Work screen: one Export flyout contains format selection and Download; the parent trigger reads Export without a format readout. The tick retains the mounted menu’s local selection. The filename is derived for the downloaded file and is not displayed in this menu. JSON and YAML are supported.

Current implementation

convex/folio/writePolicy.ts owns write enforcement. Document, Module, and Block adapters construct authority and trusted target facts, invoke that policy before their rules, and keep their distinct persistence epilogues. The policy returns no resolved descriptor to mutation logic. Block Library promotion and Module-owned insertion use separate deep operation CodeModules because their source/transaction shapes do not fit the live assembled-Block envelope.

Every registered write now declares an exact operation. The policy retains its capability-only shape for a future adapter that has facts but no vocabulary yet; no current call site uses it.

In plain terms: before a change is saved, the server no longer just checks that you hold the right kind of permission — it re-asks the exact question "is this particular action available on this particular thing right now?", using facts it looked up itself rather than anything the caller claimed.

Projection-only consumers, including Block-agent instruction selection, continue to call resolveFolioOperations directly because they do not enforce a write.

Target — the executors collapse, and what has to happen first

In plain terms: today "rename" exists three times, once per drawer of the filing cabinet. One drawer should mean one rename that works on any card. That is worth writing down before anyone builds three of them again — but several things have to be settled first, and three of the operations do not collapse at all.

Current (above) is one resolution with kind adapters: Document, Module and Block mutations each construct their own authority and trusted facts, call writePolicy, and keep their own persistence epilogue. That shape is right for three tiers in three pairs of tables.

Target, under the unified Folio schema (Folios, GB-152): one executor per operation-catalogue row, taking a folioKey and working for every kind it applies to. The catalogue is already kind-agnostic; what collapses is the executor layer beneath the resolver. An implementer reading only the Current section would rebuild the three adapters against folios, which is what this Target exists to prevent.

The resolver is not already ready, and saying so was wrong

An earlier draft of this section claimed the resolution does not change. It does, in two specific ways an implementer meets immediately:

  • Two kind vocabularies. FolioOperationFacts.folio.kind is FolioKind — document | module | block — while the stored discriminator is FolioSchemaKind, six kinds with block split into content_block and data_block. schema-kinds.ts says the two coexist deliberately until the frontend migrates. So an executor that reads folios.kind cannot pass it to the resolver: it needs a bridge (content_block → {kind: "block", payload: "content"}, data_block → …"fields"), and workspace and project have no target in the resolver's vocabulary at all. Building that bridge is the first task of the collapse, not an incidental. Built 2026-09-02: packages/domain/src/folio/schema-kind-operation-target.ts is that one keyed table, and convex/folio/operationPrologue.ts is the only place an executor crosses it.
  • resolveApplicability branches on kind in several places, and the catalogue carries four kind-branching helpers (removeLabel, arrangementCapability, renameCapability, duplicateMenuGroup). The resolver is the one place a kind switch is allowed — it is the policy that answers per-kind questions — but it is not already generic, and the target schema's enforcement clause ("no kind-switch outside the keyed tables") is about executors and payloads, not about this resolver.

The one legitimate per-kind branch in an executor

The payload edit, and it lives in the keyed payload union (folioPayloadValidatorByKind), never in a handler. Note that the one unified handler that writes a payload today reaches the column by cast rather than through requirePayloadAgreement; putting the keyed union on that path is part of the collapse.

What does not collapse

  • write-document-guide stays its own operation. The resolver states the reason where it is defined — "deliberately its own member rather than a facet of write-edit: a guide is not document content" — and that decision stands. It is also not a naming artefact that can be renamed away: it is a stored value in the proposals.operations column, replayed on acceptance, scoped, rendered in the proposal schedule, and named verbatim in Goldie's tool description. Collapsing it would be a stored-data migration plus an agent-vocabulary change, and neither is Stage D's work.
  • write-module-about is not simply "the guide". It writes guide, flow, classification, display and sequence; only guide has a folioVersions column. display/sequence numbering inheritance is retired in favour of a bulk write onto descendant content blocks, and flow/classification are not migrated. Only the guide facet has a home in the unified About write.
  • write-module-output writes four things, and only one of them has no successor. The answers it routes onto each owning data block become ordinary data_block payload writes (write-field-values); moduleVersions.data alone has no successor, because a module's data namespace is folded from its pinned data blocks at read time and folioVersions carries no data column; interviewStatus / statusReason are settled by A Question Is Done When It Is Answered or Skipped (skipped is a boolean on the field; nothing above it stores progress); and its flow write-safety gate has no successor because flow is not migrated. Deleting this row wholesale would silently drop the agent's answer-write path.
  • The Library journey rows (promotion, insertion) keep their separate deep operation modules. Stage D writes touch no library table at all, and "the page boundary is the only adapter from the old verbs" is not a claim about them.
  • The reviewed-edit rows (propose-edit) are Stage E, not Stage D. The collapse described here stops at the direct writes.

What the collapsed executor must stop doing

The block adapter today writes three rows, not two: the block version, optionally the block row, and the parent module version — stamping editedAt and a recomputed data fold onto it. Both of those now have read- time successors: the deepest edit is folded on read, and a module's data and dataSchema are folded from its pinned data blocks. A collapsed executor must not stamp the parent. An implementer porting the existing envelope will carry that epilogue across by default, and it would reintroduce exactly the stored derivation the unified read layer removed.

Three further executor rules are not table-shape artefacts, and each needed a decision rather than a port. Where they stand after 2026-09-02:

  • Head version — settled. The legacy requirement differed by tier (a Document renamed with no live version; Module and Block writes required one). Under the unified model every executor requires an open live head, a Document included, lifecycle included: the plan's settled decision 6 says every edit patches an open live draft and its save-point algorithm gives every active Folio one, so a missing or sealed head is a broken state no operation should proceed in, not one Delete should tolerate. The sealed-head predicate and its wording live once in convex/folio/writeGuards.ts (headVersionSealed), read by the About seam and the executor prologue alike.
  • Lock — settled (owner, 2026-09-02), then retired (owner, 2026-09-11): access rules are kind-agnostic. As settled, the stored fact was a role list (access.locked_for) on every kind's head version; the resolver's fact is one boolean, and the prologue translates the shape — locked when the list names the effective role. The resolver's lock rule now applies to every kind, not only blocks: a locked folio hides its write-data operations from a session without write-structure, with write-field-values the one exception (answering a question is not restructuring the form). Every member of the About card's access object — locked_for now, hidden_for, library and sharing when they ship — follows the same rule regardless of kind.
  • Shared prologue — built. convex/folio/operationPrologue.ts reads the parent kind through the bridge and the sibling position from the parent head's folioPins.by_parent_version in index order, so move's first/last-sibling disabled states are enforceable server-side for the first time. A relationship is stated only for a folio its parent's head actually schedules; an unscheduled child has none, and the resolver's missing-parent rule hides the arrangement operations for it.
  • Executors are declared, not assembled (GB-173, 2026-09-04). convex/folio/folioOperation.ts wraps the prologue in convex-helpers' customMutation: an executor declares its catalogue row and its refusal wording beside its args and handler, the builder adds the three operation arguments to its validator, and the prologue runs before the handler by construction, handing it the authorised view as ctx.operation. No executor calls the prologue itself, and folioOperationGuard.test.ts refuses a production module that does.

Gap

The collapse has started and is not finished. Phase 1 built the invariant-bearing write primitives (convex/folio/writes.ts, pinWrites.ts, schemaAboutWrite.ts) and the read layer followed. The first enforced unified writes landed 2026-09-02 — delete and restore in convex/folio/lifecycleWrites.ts, then rename in convex/folio/namingWrites.ts, then save-version in convex/folio/versionWrites.ts (2026-09-04: a new version is an act on any card, from that card's own menu — owner ruling). Two of that set have since been retired with their operations — write-lock by GB-230 and write-file-name by the 2026-09-09 derived-download-name ruling — and their executors were deleted. Each is one executor for any kind the resolver admits, declared through folioOperation.ts so that operationPrologue.ts's authority → live head → facts → requireFolioWritePolicy sequence runs before the handler by construction, and each patching exactly the rows its operation is about. The remaining catalogue rows, and the unified About write (which still has no caller, no authority and no policy call), are still to collapse. The legacy executors named under Current were deleted with the legacy tables in the plan's Stage B (2026-09-03). The six Stage C executors are public since 2026-09-04 and wait on the screen to bind them; Stage D wires each further one as it lands. The sequence's authority is GB-152; the read and write contracts it landed are Folios.

The Decision section's "exhaustive for implemented Folios only: Document, Module, and Block" is Current, and describes operation coverage in the three-kind UI vocabulary — not the six-kind storage vocabulary, which those two sentences below about Project's adapter also predate.

Operation families

The operation vocabulary is intentionally not generic CRUD:

FamilyRule
InsertionThe parent owns adding and placing its child: Document → Module, Module → Block, later Project → Document. The child adapter mints identity and its initial version.
ArrangementMove and Remove act on the parent-child arrangement. Remove Module and Remove Block are not Delete or hard deletion.
DuplicationThe source owns copy-write branching and lineage; the parent arrangement places the branch, normally after its source.
LifecycleDelete / Put back (owner ruling, 2026-09-04: on any Folio kind — Document, Module or Block) is reversible list visibility. The 2026-09-14 owner ruling extends Delete to the owned subtree after confirmation; Put back restores that deletion cohort, leaving earlier deletions intact. Permanent, unrecoverable destruction is a separate decision this product does not build.
Library journeyPromotion and insertion have reuse-specific lineage, classification, value-safety, and destination-validation rules.
Reviewed editProposed Change owns propose → diff → revise → accept/reject → apply. Direct collected-value writes remain immediate writes under their own guards.

For reviewed edits, the resolved operation is the shared input to the agent's allowed output and to server enforcement. Adapters keep the distinct content, About, and Field-structure representations, complete-source rules, compilers, and patches; the common proposal lifecycle does not become a generic validator or a giant prompt string.

Why this boundary

The existing surfaces independently spell action labels, menu grouping, destructive placement, and opt-in callbacks. Separately, the block agent derives what it says it may do and a different contract gates what it may actually do. Those copies can make an unavailable action look available, or let the agent promise an outcome the server refuses.

Generic CRUD is not the remedy. It would hide materially different invariants: archiving is not removal, insertion needs a parent, duplication needs a source, and Library promotion changes reuse posture by creating a derivative. The operation resolution shares only the facts that really agree; kind adapters retain the invariants that do not.

Consequences

  • Menus are projections of resolved operations. Facet navigation, icons, trigger behavior, confirmations, toasts, and focus handling stay UI concerns.
  • The existing Proposed Change domain chapter remains its lifecycle authority; Folio operation resolution composes it rather than absorbing it.
  • Existing Document and Block-Library mutations, local Folio arrangement, and edit envelopes remain kind-specific executors. The resolver is policy, not a universal persistence service. Current only for the Document/Module/Block edit envelopes — see the Target above, which collapses those executors while leaving the Library journey's separate modules and the resolver's role unchanged.
  • Persisted Module/Block arrangement remains Builder-first MVP work. A locally available arrangement operation does not imply that it survives reload.
  • Project remains outside the exhaustive Interface until its schema and invariants exist.

Direct writes

The vocabulary exactly covers Document/Module/Block rename, duplicate, delete and restore, Block Field-value writes, new data-Block insertion, standalone Block insertion, reviewed Block edits, and the direct writes named below. Every one of those server writes re-resolves its operation from stored facts.

A direct write is not a reviewed proposal, so it does not call itself propose-edit. The direct-write kinds are:

OperationApplies toCapability
write-edit facet contenta Block whose payload is contentwrite-data
write-edit facet abouta Block of either payloadwrite-structure
write-edit facet fieldsa Block whose payload is fieldswrite-structure
write-field-valuesa Block whose payload is fieldswrite-data
write-module-abouta Modulewrite-structure
write-module-outputa Modulewrite-structure (was write-data until 2026-09-11)

write-edit deliberately mirrors propose-edit's facet grammar, and the two share one applicability rule: whether a facet exists on the Folio in front of them is the same question, and only the review lifecycle differs. A direct Content write requires write-structure exactly as its reviewed sibling does (2026-09-11), so a Maker cannot reach through the immediate path what the proposal path refuses. Until 2026-09-11 both rows took write-data and a per-folio padlock closed them to a Maker; the padlock, its write-lock row and the lockBindsBuilder guard are retired, and the capability line alone answers.

In plain terms: these are the saves that take effect straight away, with no proposal to review first. Each one now has its own name, so the server can say "this exact action does not apply here" instead of only "you lack this kind of permission" — which is what let, for example, a prose edit be accepted onto a Block that has no prose in it.

On this page