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-lockrow, nolockedfact in the resolver and noaccess.locked_forwriter. 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.
Menu presentation — GB-204
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
removeoperation 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.kindisFolioKind—document | module | block— while the stored discriminator isFolioSchemaKind, six kinds withblocksplit intocontent_blockanddata_block.schema-kinds.tssays the two coexist deliberately until the frontend migrates. So an executor that readsfolios.kindcannot pass it to the resolver: it needs a bridge (content_block → {kind: "block", payload: "content"},data_block → …"fields"), andworkspaceandprojecthave 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.tsis that one keyed table, andconvex/folio/operationPrologue.tsis the only place an executor crosses it. resolveApplicabilitybranches 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-guidestays its own operation. The resolver states the reason where it is defined — "deliberately its own member rather than a facet ofwrite-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 theproposals.operationscolumn, 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-aboutis not simply "the guide". It writesguide,flow,classification,displayandsequence; onlyguidehas afolioVersionscolumn.display/sequencenumbering inheritance is retired in favour of a bulk write onto descendant content blocks, andflow/classificationare not migrated. Only the guide facet has a home in the unified About write.write-module-outputwrites four things, and only one of them has no successor. The answers it routes onto each owning data block become ordinarydata_blockpayload writes (write-field-values);moduleVersions.dataalone has no successor, because a module's data namespace is folded from its pinned data blocks at read time andfolioVersionscarries nodatacolumn;interviewStatus/statusReasonare settled by A Question Is Done When It Is Answered or Skipped (skippedis a boolean on the field; nothing above it stores progress); and its flow write-safety gate has no successor becauseflowis 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 itswrite-dataoperations from a session withoutwrite-structure, withwrite-field-valuesthe one exception (answering a question is not restructuring the form). Every member of the About card'saccessobject —locked_fornow,hidden_for,libraryandsharingwhen they ship — follows the same rule regardless of kind. - Shared prologue — built.
convex/folio/operationPrologue.tsreads the parent kind through the bridge and the sibling position from the parent head'sfolioPins.by_parent_versionin index order, somove'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'smissing-parentrule hides the arrangement operations for it. - Executors are declared, not assembled (GB-173, 2026-09-04).
convex/folio/folioOperation.tswraps the prologue inconvex-helpers'customMutation: an executor declares its catalogue row and its refusal wording beside itsargsandhandler, the builder adds the three operation arguments to its validator, and the prologue runs before the handler by construction, handing it the authorised view asctx.operation. No executor calls the prologue itself, andfolioOperationGuard.test.tsrefuses 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:
| Family | Rule |
|---|---|
| Insertion | The parent owns adding and placing its child: Document → Module, Module → Block, later Project → Document. The child adapter mints identity and its initial version. |
| Arrangement | Move and Remove act on the parent-child arrangement. Remove Module and Remove Block are not Delete or hard deletion. |
| Duplication | The source owns copy-write branching and lineage; the parent arrangement places the branch, normally after its source. |
| Lifecycle | Delete / 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 journey | Promotion and insertion have reuse-specific lineage, classification, value-safety, and destination-validation rules. |
| Reviewed edit | Proposed 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:
| Operation | Applies to | Capability |
|---|---|---|
write-edit facet content | a Block whose payload is content | write-data |
write-edit facet about | a Block of either payload | write-structure |
write-edit facet fields | a Block whose payload is fields | write-structure |
write-field-values | a Block whose payload is fields | write-data |
write-module-about | a Module | write-structure |
write-module-output | a Module | write-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.
The Folio Canvas Shows a Listing
Owner rulings 2026-09-07 — one canvas screen shows any folio and its children; a child with a route is a card and a child without one opens in place; the canvas reads a listing rather than a folio so a Library can use it; Home is the canvas of the session's binding, and several bindings are listed; a Maker is invited to a folio and sees only its subtree; Open is offered where a card's children are not in view; a proposed container is decided whole or item by item.
The Folio Schema
The settled shape of the unified Folio storage — six recursive tables, twenty-nine binding constraints with their owner rulings, the printed About card grammar, the six-pillar review lens, and the two stages (proposals and Goldie; docs made wholly Current) that Backlog tasks still own.