Proposals Schema
The proposals table — how the agent files a structural change against one Working Document, and how a Builder accepts or rejects it whole
Re-addressed to Folios 2026-09-15 (GB-248)
The filing, acceptance and rejection doors below are live against the
unified folios tables: convex/folio/proposalWrites.ts,
convex/folio/proposalDecisionWrites.ts and convex/folio/proposalReads.ts,
with the column validators in convex/folio/proposalSchedule.ts. Every
address is a folio key; the baseline is the Document's working revision.
Current — the
proposalstable, theScheduledOperationdomain types, thefileProposal(internal) /acceptProposal/rejectProposalmutations and theopenProposalquery are implemented and tested. Filing validates shape, admitted membership, backward-only refs, the anchor's reach and the one-open rule; acceptance replays the schedule through the executor bodies a Builder's own click reaches, running the operation prologue for each, inside one Folio transaction at the filed revision.Goldie files from her turn action (GB-277,
convex/goldie/tools.ts), and the canvas shows and decides the filed proposal (GB-201): the page subscribes toopenProposal, the Review Feature (src/features/review-folio-proposal) binds Accept and Reject and holds the pending and refusal state, and the canvas draws the memo on the card the proposal is anchored to (folioKey): Reject / Accept inside the reply row beside Send, and each change on the thing it changes. The conversation carries no memo line of its own (owner, 2026-09-15: Goldie's reply already says what she proposes, and a line that vanished on decision shifted the card). A stale baseline is shown in the card's notice row as the server's own sentence.Landed 2026-09-15 (GB-201, GB-176, GB-278): a filed rewrite of a content block is marked up word by word on that block's reading face (the
diffpackage beneath it, /docs/decisions/buy-versus-build); a proposed insertion is drawn as a provisional card where it would land — dashed, striped insertion spine, "Accept insertion / Reject insertion / View" in the reserved action row (src/widgets/drafting-editor/model/proposal-provisional-rows.ts); and a container is decided whole or item by item (rule 10, below): a Steps menu beside the pair and each provisional card's own bar decide one step,decideProposalItemrecords it initemDecisions, and the row resolves on its last item. The memo names the kind of thing each step touches.
A proposal is one filed, not-yet-applied structural change to one
Working Document. The agent files it; a
Builder (the role holding
write-structure) accepts or rejects it. Nothing moves until acceptance.
In plain terms: a marked-up draft that a junior sends up the line, sitting beside the file rather than in it. Accepting it is the moment the changes are engrossed; rejecting it leaves no mark that it was ever suggested.
Why a separate table
A proposal is its own row, never a Block/Module version and never a column on the row it targets. A rejected proposal must leave no trace in version lineage — versions record what happened, and a declined suggestion did not happen. And a proposal may create entities that do not exist yet, or span several Modules and Blocks, so no single canonical row could hold it.
The proposals row
Indexed metadata the app can query without opening the change, plus the change itself as one ordered schedule.
Prop
Type
Timestamps. Convex _creationTime is the filing timestamp; there is no
proposedAt and no createdAt (Convex rules).
What each step replaces (befores)
fileProposalFor reads each step's old words from the heads just before it
inserts the row, and stores them in befores, one entry per step beside
operations. They are written once at filing and never rewritten: once a step
is accepted the head reads only the new, so nothing later can recover the old.
The chat's proposal list shows old → new from them, and they are the base of the
drafting history (which AI changes were accepted).
| Step | Recorded before |
|---|---|
rename | The folio's old name (its label, else its handle, as resolved for display). |
write-edit content | The head version's wording (payload.text). |
write-edit fields rename or delete | The field's old name, found by fieldKey in the head's payload.fields. |
write-edit about, write-module-about, write-document-guide | The head version's guide. |
An entry is null when the step replaces nothing: an insert; a target that is
a placeholder ref ($new1, $new1.block) made by an earlier step; a field
add; a guide or wording that was empty; or a target that cannot be found. Rows filed
before this field existed have no befores; the list falls back to the live
name.
In plain terms: the memo keeps a photocopy of each passage it proposes to change, so the file always shows what the draft said before, even after the change is accepted.
Indexes
| Table | Index | Purpose |
|---|---|---|
proposals | by_key on (key) | Business-identifier lookup. |
proposals | by_document_status on (documentKey, status) | The open-proposal check, and "show me this document's open proposal". |
proposals | by_thread on (threadKey) | Trace a proposal back to its conversation. |
Proposals queue on a document
Owner ruling 2026-10-07 (Proposals Wait for an Explicit
Accept): a document
may hold several open proposals. Filing never accepts or blocks on the one
already open; the new one joins the queue. openProposal returns the head of
the queue (the oldest open row). Accepting one moves every other open
proposal filed against the same revision up to the revision the accept
produced (advanceQueue in proposalDecisionWrites.ts), so the queue is not
stranded as stale. MVP rule: a document has at most one — retired 2026-10-07.open
proposal
In plain terms: memos queue in the tray; signing the top one does not make the ones under it out of date.
The operations schedule
The change is an ordered array of ScheduledOperation — a type defined once in
packages/domain/src/proposed-change/, imported by both the filing and the
acceptance path. It embeds the existing FolioOperation value
(packages/domain/src/folio/operation-resolution.ts) as its discriminant, and
its args are the executor's own arguments with the target's address beside
them — { operation: FolioOperation; args }.
There is deliberately no forked operation vocabulary and no opaque blob: a Builder's click and Goldie's proposal name the same catalogue row, and acceptance replays the row through the very executor the click reaches.
In plain terms: the memo lists the very actions the menu offers, each naming the card it is about, so the clerk applies an accepted memo with the same stamps a partner's own edits get.
Admitted operations
Each row names the executor body acceptance replays it through — exported from the executor's own module, so the click and the replay share one body and one refusal wording.
| Admitted operation | Embedded FolioOperation | Args | Replays through |
|---|---|---|---|
| Insert module | { kind: "insert", child: "module" } | parent (the Document), optional after (a sibling module), optional guide and tags | insertFolioChild, then Goldie's tag door (compositionWrites.ts) |
| Insert content block | { kind: "insert", child: "block", payload: "content", source: "new" } | parent (a module), optional after (a sibling block), optional guide and tags | insertFolioChild, then Goldie's tag door |
| Insert data block | { kind: "insert", child: "block", payload: "fields", source: "new" } | parent, optional after, optional guide and tags | insertFolioChild, then Goldie's tag door |
| Keep a Library copy (block) | { kind: "insert", child: "block", payload, source: "library" } | parent, optional after, copy (the staged copy's own key), entry (folioKey, libraryKey), optional alternatives (up to two, each with label and fit), optional guide and tags | clears folios.stagedAt on the copy, then writes the proposed guide and tags; reject removes the copy (library/stagedWrites.ts) |
| Keep a Library copy (module) | { kind: "insert", child: "module", source: "library" } | as above | as above |
| Content-block text | { kind: "write-edit", facet: "content" } | target, text | writeContentOnHead (contentWrites.ts) |
| Data-block field structure | { kind: "write-edit", facet: "fields" } | target, operation (a FieldStructureOperation) | applyFieldStructure (fieldStructureWrites.ts) |
| Block guide | { kind: "write-edit", facet: "about" } | target, guide (text or null) | writeGuide (aboutWrites.ts) |
| Module guide | { kind: "write-module-about" } | target, guide | writeGuide |
| Document guide | { kind: "write-document-guide" } | target, guide | writeGuide |
| Rename any folio | { kind: "rename" } | target, label | renameFolioRow (namingWrites.ts) |
Edits embed write-edit, never propose-edit. The propose-edit /
write-edit pair encodes the card-level review flow, and the proposal
mechanism on this page is the review — embedding propose-edit would nest
one review inside another. Two consequences follow, both wanted: at acceptance
the schedule performs writes, and the filing-time scope check evaluates the
write-edit operation's required capability, the one actually exercised.
Field-structure args
The fields facet carries the field-structure executor's own operation
verbatim — fieldStructureOperationSchema
(packages/domain/src/field/block-field-operation-schema.ts), from which the
executor's Convex validator and this column both derive: add (optional
label, naming the field as it is born; optional type from the retype
vocabulary, Text when absent; optional afterFieldKey, the existing field the
new one lands directly below, and optional atStart, which lands it first —
one or the other, never both, and appending when neither); rename
(fieldKey, label); retype (fieldKey, type); move (fieldKey,
direction); delete (fieldKey); set_required (fieldKey, required).
There is no second field vocabulary for proposals.
A rename changes the typed name and the handle projected from it; the
fld_ identity underneath is stable, and references bind to that identity,
never to the handle. A proposed rename is therefore not reference-breaking
and needs no carve-out; the executor's sibling-handle guard is the last word.
A field is never addressed by placeholder. add mints its fld_ key at
acceptance and no later step can reach it — there is no $new for a field —
so a new field is named in the add step itself ({ kind: "add", label }).
Filing refuses a placeholder fieldKey in those words (owner's stuck memo,
2026-09-15: "add" then "rename $new1" was accepted at filing and refused at
acceptance as a field not found). An add's afterFieldKey addresses an
existing field, so the same rule covers it: a placeholder there is refused at
filing, and an anchor the block does not hold is refused at acceptance rather
than quietly appended.
In plain terms: a suggestion can say which existing question the new one should sit under, but it cannot point at a question the same suggestion is still about to create.
What is excluded, and why
| Excluded | Why |
|---|---|
| Field values | A Maker's writes and the side panel's job (The Side-Panel Goldie Is Maker Only); never travel through structure proposals. |
| Tags | Out of phase 1 by owner ruling 2026-09-15: no tag writer exists, and Goldie proposing tags is its own later ruling. |
| A block's handle | Machinery nobody dictates to Goldie; a name is proposed through rename. |
| Arrangement and lifecycle — move, remove, duplicate, delete, restore, save version | Rearrangement a Builder does by hand. Any may join later as one more admitted row. |
| Library inserts | Admitted since 2026-09-30 as the rows above: the copy is staged at filing and the memo keeps or returns it (Goldie Finds a Clause and Stages It). |
Target: proposal capability reaches user capability
The admitted list is an incremental delivery boundary, not a permanent ceiling. Each addition extends the shared typed catalogue and the one replay pipeline; none creates a feature-specific proposal path.
Addressing
Payloads store stable folio identities only — folios.key, whatever
prefix a row wears (fol_, or a legacy tier prefix a migrated row still
carries) — never a version key, never a handle. A key's prefix says nothing
about its kind: the kind is a fact the filing mutation reads from the row and
hands the scope resolver as data (ProposalFolioFact).
The Document itself is addressed by its own key, like any other folio.
Filing refuses any address that does not sit beneath the proposal's Document
as unknown-folio, so a schedule can never reach into another file.
In plain terms: the note names each card by its file number, and the clerk checks every number is in this file before the note goes in the tray.
Resolution is sequential — per operation, mid-replay. Each operation resolves its addresses against the document as the operations before it in the same schedule left it: a rename in step two changes the name step four is shown under, and a folio created in step one has no key until step one has run.
Baseline staleness
Settled 2026-09-06, before Stage E: the baseline is the Document's working
revision, not its head version key alone. The live head is edited in place
and keeps its folioVersions.key between saves, so a baseline made only of
the key would accept a proposal reviewed against prose that has since been
rewritten. The root Document row carries a monotonic workingRevision
(Folios → The working revision);
every material write anywhere in the tree advances it exactly once, a no-op or
a refusal never does, and a multi-operation acceptance advances it once for
the whole accepted transaction.
- Filing (
fileProposal) records the Document's current revision asbaselineRevision(and the head key asbaselineVersionKey, for the reader). Filing writes nothing material, so it never moves the revision. - Acceptance (
acceptProposal) runs insiderunFolioTransactionwithexpectedRevision: baselineRevision: the transaction is refused asstalebefore any operation runs when the revision has moved, and otherwise replays the whole schedule and advances the revision once. A refused acceptance changes nothing; the agent files afresh against the new baseline. - Sealed historical editions are unaffected: the revision is a fact about the working Document, and versions carry nothing of it.
In plain terms: the suggestion says which edit count of the file it was written against. If the file's count has moved on, the suggestion is handed back for a fresh look rather than applied to a file its author never saw.
The invariant. A proposal identifies the exact state it proposes to apply — the Document, its revision, and stable folio identities for every target — and acceptance either applies that reviewed state atomically or refuses it as stale. There is no third outcome.
One baseline per proposal, and it is the Document's. Per-target baselines — one per Module or Block touched, so an unrelated edit elsewhere does not stale the whole proposal — are a later non-breaking loosening, not MVP.
Placeholder refs
A later operation in a schedule may target an entity an earlier operation
creates; refs resolve to real keys only at acceptance. The syntax and binding
rule below were settled by owner ruling on 2026-08-18, and
packages/domain/src/proposed-change/placeholder-refs.ts implements them.
Syntax. A ref is the string $new1, $new2, … — a $new prefix plus a
1-based ordinal counting creating operations in schedule order. It appears
anywhere a folio key would appear, and the replay substitutes the minted key
at acceptance.
Binding rule. A creating operation binds the entity it creates; where
the underlying write creates more than one entity, the extra bindings are named
fields of the same ref rather than further ordinals. Concretely, insert-module
binds both the Module and the starter content Block that is born with it,
because insertFolioChild returns the starter with the module: $new1 is the
Module, and $new1.block is its starter content Block. Insert-content-block
and insert-data-block bind one entity each, addressed as the bare ref.
Validation. A ref must be bound by an earlier operation in the same schedule; a forward or dangling ref is refused at filing time, not at acceptance. A created folio is within its creator's reach wherever it lands, so a proposed new clause can be given its words in the same memo.
In plain terms: a suggestion may say "add a new clause, then rename it" — and
the new clause has no name yet when the note is written. $new1 is a
placeholder that means "the thing step 1 creates", filled in only when the
changes are actually made.
Validation timing
The split below was settled by owner ruling on 2026-08-18;
convex/folio/proposalWrites.ts and convex/folio/proposalDecisionWrites.ts
implement it.
At filing, the mutation validates: the schedule's shape (each entry
parses as a ScheduledOperation, args match the embedded discriminant);
admitted-operation membership (nothing outside the admitted MVP table);
scope (every operation is permitted by the anchor × capabilities resolver
for this invocation); placeholder refs bind backwards; and the
one-open-proposal invariant. Rename entries also check the shared naming
rules against stored siblings and earlier names in the schedule. Deleted
siblings still reserve names and handles. A naming conflict returns to Goldie
before the proposal appears, so she can correct it; stable folio keys remain
the references throughout.
At acceptance, the mutation re-validates baseline freshness, resolves
placeholder refs to minted keys as each creating operation runs, and then
replays the schedule through the executor bodies, running the operation
prologue (requireFolioOperation) for every operation on the folio it
targets. The executors' own guards — authority, live head, policy, handle
uniqueness, field identity — remain the last word. Acceptance never
bypasses or duplicates them; it is the one composite executor the prologue
guard admits beside the builder.
In plain terms: the suggestion is checked for form when it is filed, so an impossible one never reaches the Builder's tray. It is checked for substance when it is accepted, because that is the only moment the document's real state matters — and the ordinary save rules still apply, exactly as if a person had typed the change.
Scope is anchor × capabilities
What Goldie may propose is the product of which folio she is anchored to
(folioKey) and what the caller may do (their capabilities), resolved by
one resolver (packages/domain/src/proposed-change/proposal-scope.ts) that
reads capability and applicability from the Folio operation resolution and
decides only the anchor's reach
(One Goldie Per Folio).
| Anchor | Reach |
|---|---|
| Any folio | Edits to itself and its descendants — a block's Goldie edits that block; a module's Goldie edits the module and its blocks; the Document's Goldie reaches every folio, the Document's name and guide included. |
| Any folio | Insertions whose parent is within that reach, or that land immediately after the anchor itself. |
| Any folio | Every folio the same schedule creates, wherever it lands. |
| A block | A new module immediately after the module she sits in — one semantic destination, never further down, inside another module, or above (owner ruling 2026-09-15, GB-278); the Builder moves an accepted module with the ordinary reorder. |
Naming. Code always says agent and proposal; Goldie appears only in
visible copy, read from AGENT_DISPLAY_NAME (packages/domain/src/agent/display-name.ts).
Accept and reject
Retired 2026-10-07 — nothing applies without an explicit accept (the ruling). Sending Goldie the next message, filing a new proposal, or turning to another card accepts nothing; the turn-start settle and the canvas's moving-on settle are removed. The paragraph below is history.
Moving on accepts (owner ruling, 2026-09-15). A proposal is not left
open. It is shown on the card whose conversation filed it for as long as that
conversation is the one open; the moment the Builder moves on — sends Goldie
the next message anywhere on the document, opens another card's conversation,
shuts the panel, or returns to a document with a memo waiting — the proposal
is accepted without a click, the way a coding agent's shown diff takes effect.
Reject is the one deliberate act, pressed while the card is open; pressing
Accept only does early what moving on would do. When the acceptance is
refused because the document moved on since filing, the proposal is set
aside — rejected, so the tray is clear for her next memo — and the card's
notice row says so in the server's words. Server side this runs at the start
of every turn (convex/goldie/settleProposal.ts); on the canvas it runs when
the panel leaves the anchor card (features/review-folio-proposal).
In plain terms: the junior's memo is taken as signed when you turn to the next thing. Sending it back is the only decision you ever need to make.
Accept is all-or-nothing and atomic. One Convex mutation checks the baseline, then replays the whole schedule in order through the canonical authoring mutations. Real rows and versions exist only from that moment, and the result is indistinguishable from structure a person authored by hand.
Reject flips status to rejected, sets decidedAt, and touches nothing
else. The document is unchanged and its version lineage records nothing.
Subset acceptance is a deliberate non-goal for MVP. "Accept some of it" is a conversation: reject with feedback and the agent files a narrower re-proposal. The operations-list shape leaves the door open — subset acceptance would be a later non-breaking extension, not a reshaping.
In plain terms: you sign the marked-up draft or you send it back. There is no initialling three amendments out of five, because a half-applied set of structural changes can leave the document saying something nobody drafted.
Item by item — ruled 2026-09-07 (owner; the Folio Canvas record, rule 10),
built 2026-09-15 (GB-201): a proposed container is decided whole or item by
item. A proposed module is one proposal with a schedule beneath it. Accept
on the container takes every step still open and Reject sends them all back —
the whole route above — or the reviewer decides each step on its own
(decideProposalItem, on the thing each step changes — a provisional
card's own review bar, or the Reject / Accept pair on a data block's tinted
field row (owner ruling 2026-09-22: one vocabulary at every grain, not "Keep"
for a field beside "Accept" for the whole memo)), and the container resolves
when its last step does: applied,
replaying only the steps taken, when anything was; rejected when nothing was.
Each step's decision is recorded against the schedule entry
(itemDecisions) and the row's status derives from them. The schedule's
refs impose one rule (packages/domain/src/proposed-change/item-decisions.ts):
deciding a creating step decides its undecided dependants with it, taken or
sent back, transitively — so Accept on a provisional card lands the new
folio with its words, its name and anything proposed inside it — a
dependant cannot be taken while its creator stands rejected, and a creator
cannot be sent back once a dependant was taken. Placeholder ordinals stay
positional over the whole schedule, so a rejected step never renumbers the
steps after it. This is the "later non-breaking extension" the subset
paragraph reserved.
Where a step is decided — ruled 2026-09-15 (owner), built the same day (GB-282). A Steps dropdown beside the pair used to list every numbered change with its own Accept and Reject. It is retired: a change is judged where it is drawn, not in a list of sentences about it. So each step carries its decision on the thing it changes, in the shape of assistant-ui's Reviewable Diff element — the pair at the top right of the hunk. The reply row keeps Accept and Reject for the whole memo, and moving on still applies everything left open.
Narrowed 2026-09-16 (owner): a prose clause and an About row carry no pair.
Only a provisional card and a data block's field row still decide themselves.
On a clause and on an About row the pair asked the question the reply row below
already asks, so what is drawn there is the markup alone — and, where the memo
touches something, a gold dot: on the card's trailing corner for a clause, and
on the panel's About tab for an About row (since 2026-09-22; before that
beside the About this ... fold-out), which is what an unchosen tab has to
say about what it is hiding.
The dot is gold whatever the rewrite does. Colouring it by operation restated the lie the field-row tints already refuse — that a rewrite is an "addition" — and put a three-way code in six pixels; the markup and the dot's accessible name both say what changed.
In plain terms: you initial each amendment where it can be initialled, and sign or send back the whole memo at the bottom.
The marker on the conversation's rail
Ruled 2026-09-15 (owner), built the same day (GB-281). A filed proposal is
recorded in the anchor card's conversation as a quiet note — the shared
transcript marker, as muted text at the left of the rail (no rail line and no
date line, owner ruling 2026-09-16) — written at filing and never removed. It reads Proposed · 3 changes while open and 2 of 3 accepted · 1 open while it is being decided.
A decided memo draws two lines, not one — ruled 2026-09-16 (owner),
superseding the single mutating marker above. The record is the series:
the filing line, the reply that announced it, then what was decided. One line
whose words changed could show only the last of those, so a reader could not
see that a proposal had been made at all. The outcome line reads Accepted · 3 changes, Accepted 2 of 3, 1 sent back, Sent back, or Set aside · <the server's sentence> — the count survives the decision, so the two lines read as
one note in two states.
The filing line is placed against the reply it announces, not by the clock: the
proposals row is written after that message is saved, so _creationTime alone
put the caption underneath the sentence it captions. The outcome line keeps
sorting by decidedAt, so conversation that happened while the memo sat open
falls between the two.
This retires the rule that deciding must not move the card (2026-09-15): an outcome line is a new row. What holds is the top edge — the card grows downward, so the reader's place above the click is kept.
Nothing new is stored. The marker is a view of this row: _creationTime is
where it sits on the rail, itemDecisions and status are what it says, and
the thread's memos are reached through the by_thread index
(goldie/threads:listProposals). The one thing the row does not hold is the
refusal a settle came back with; that sentence belongs to the turn that met it
(settleOpenProposalForTurn), so the tab that saw it puts it on the marker and
every later reader sees the plain "Set aside".
In plain terms: the file note that says "memo filed", and later "signed" — never crossed out.
Drafting rulings
The document-scoped pair — proposing the document's name and its
guide — was admitted by owner ruling on 2026-08-18, on the same terms as
everything else here: from the Document's own Goldie, and inside the
propose-then-accept loop, so the agent still holds no direct write path. The
guide is the author's standing instruction to the agent and never renders as
document content; writeDocumentGuide is its canonical human executor, and
the replay reuses its body.
All of this page's once-open drafting points were settled by owner ruling on
2026-08-18, as implemented: the prop_ key prefix, the decidedBy column
(server-derived, so a decision always has an actor), the placeholder-ref
syntax, and the filing/acceptance validation split.