AI-Assisted Editing Foundations — Design
Date: 2026-10-08 Status: Approved (brainstorming)
Context
Apitomy Axiom will offer an AI-based workflow creation experience in which the user makes manual and
AI-assisted edits concurrently. Apitomy Flow (@apitomy/flow-ui) is the canvas for that experience. This
spec covers the Flow-side foundations (sub-projects 1–3 of the feature set below).
Decisions
| Topic | Decision |
|---|---|
| Where the agent runs | Outside Flow (Axiom/host). Flow is AI-agnostic and exposes an edit API. |
| How AI edits land | Staged change sets are the primitive; "apply live" = auto-accepting a change set as one tagged, undoable transaction. |
| Action Types / tools authoring | Not owned by Flow. Axiom has its own editors. |
| AI entry points in Flow | Host-defined context hooks only; no chat and no agent-status UI in Flow. |
| Conflicts | Strict base-revision check; stale change sets are rejected and the host re-asks the agent. |
| Server parity | Change-set application and contentRevision ship in both TS (ui/) and Java (engine/), verified by shared conformance/ fixtures. |
| Per-turn grouping | No Flow support. Axiom accumulates a turn's change sets server-side and proposes one combined set. |
Overall Feature Set
- Revision and origin model (this spec)
- Change sets and imperative editor handle (this spec)
- Proposal review UX (this spec)
- Context hooks: host
contextActionsin canvas, node, edge and selection menus and on Problems panel rows, receiving aFlowContext { workflow, contentRevision, selection, problems, position? }(future spec) - Live Action Type catalog: subscribable
ActionTypeProvider, "unresolved" rendering for unknown action types,onOpenActionType(value)host callback (future spec)
Out of scope: in-Flow chat, agent status display, per-change rebase, Action Type / tool editing in Flow.
§1 Revision and Origin
contentRevision:"sha256:" + hex(SHA-256(JCS(stripLayout(workflow)))).JCSis RFC 8785 JSON canonicalization.stripLayoutremovespositionfrom every node. All other fields, including workflowid,name,description,versionand config extension keys, are part of the content.- It is distinct from the existing internal
revisioncounter, which also increments on layout-only edits. Layout-only edits do not change it, and undo to identical content restores the identical value. - It is implemented in TS (
ui/src/changeset/contentRevision.ts) and in Java (ContentRevisioninengine/). Both are verified byconformance/content-revision.json. - Hosts computing it server-side must hash exactly the Flow
Workflowdocument they send to the editor, with no host-specific fields added. - Origin:
EditorCommandgains optionalorigin?: 'user' | 'host' |agent:${string}` (default'user'`). Undo snapshots record the origin of the change that produced them so undo labels can name it. onChangebecomesonChange(workflow, meta: { contentRevision, origin }). The added argument is backwards compatible.handle.replace(workflow, origin)replaces the document via the existingimportcommand as one undo step. Theworkflowprop remains seed-only.onSelectionChange({ nodeIds, edgeIds }): fires only when the selection actually changes. Hosts use it to open related tabs (such as Action Type editors) and to feed "user is looking at X" context to the agent. It is pulled forward from the context-hooks work.
Placement of positionless nodes
Today import calls needsLayout, which returns true when any node lacks a position. It then lays out
every node again, which would discard the user's manual layout whenever a server-produced document adds one
positionless node. The new behaviour:
- A pure helper,
placeNewNodes(workflow), positions only the nodes that have no position. It places each one near its connected neighbours, falling back to a free spot below the existing bounds. import(and thereforereplace) usesplaceNewNodeswhen at least one node already has a position. FulllayoutWorkflowis used only when no node has a position.applyChangeSetusesplaceNewNodesfor added nodes.- Placement is presentation only and is not part of the conformance fixtures. Java does not place nodes.
§2 Change Sets
interface ChangeSet {
id: string;
baseRevision: string; // contentRevision the agent based this on
author: `agent:${string}` | 'host';
summary: string;
ops: ChangeOp[];
}
type ChangeOp =
| { op: 'addNode'; node: WorkflowNode }
| { op: 'updateNode'; id: string; patch: { name?: string; config?: Record<string, unknown> };
unset?: string[] }
| { op: 'renameNode'; id: string; newId: string }
| { op: 'removeNode'; id: string }
| { op: 'addEdge'; edge: WorkflowEdge }
| { op: 'updateEdge'; id: string; patch: Partial<Omit<WorkflowEdge, 'id'>>; unset?: string[] }
| { op: 'removeEdge'; id: string }
| { op: 'metadata'; patch: Partial<Pick<Workflow, 'name' | 'description' | 'version'>> };
Op semantics:
updateNode.patch.configis merged shallowly into the existing config. Nested values, such as theinputsandoutputsmappings, are replaced whole.updateNode.unsetlists top-level config keys to remove, for exampletimeoutorcorrelationKey.updateEdge.unsetlists top-level edge fields to remove, for examplecondition.unsetis applied afterpatch, and a key appearing in both ismalformed.nullin a patch is stored as a value, not treated as a removal.- Because
unsetand patches operate on top-level keys only, removing one entry from a nested mapping means resending the whole mapping. For example, to drop inputfoofrom an action node, sendpatch.config.inputscontaining every remaining input;unset: ["inputs"]would remove all of them. renameNoderewrites thesource/targetof attached edges, the same as the existingrenameNodecommand.removeNodealso removes all edges attached to the node.- Ops apply in order. Later ops see the effects of earlier ops.
Pure core, in ui/src/changeset/applyChangeSet.ts:
applyChangeSet(workflow, changeSet)returns{ ok: true; workflow }or{ ok: false; error: ChangeSetError }.ChangeSetErroris{ code, opIndex?, reason }.reasonis human-readable, andcodeis one of the stable values below, which hosts may map to agent prompt hints:
| Code | Meaning |
|---|---|
target-missing |
The op references a node or edge that does not exist. |
duplicate-id |
An added or renamed element's id already exists. |
edge-endpoint-missing |
An edge's source or target does not exist. |
malformed |
The op is structurally invalid, for example a key in both patch and unset. |
read-only |
The editor is read-only. Handle only. |
stale |
baseRevision does not match contentRevision. No opIndex. |
- It is atomic: any error leaves the workflow unchanged.
- Java parity:
ChangeSets.apply(workflow, changeSet)inengine/has the same semantics and the same error codes. Both implementations are verified byconformance/changesets.json. Each fixture case gives an input workflow, a change set, and either the expected output workflow (compared with layout stripped) or the expected errorcodeandopIndex. - Added nodes without a
positionare placed byplaceNewNodes(see §1).
Imperative handle, exposed through a ref on WorkflowEditor. None of these methods throw.
| Method | Result |
|---|---|
propose(cs) |
{ status: 'staged' } or { status: 'rejected', error } |
apply(cs) |
{ status: 'applied' } or { status: 'rejected', error } |
clearHighlights() |
void |
withdraw(id) |
void |
replace(workflow, origin) |
void |
getSnapshot() |
{ workflow, contentRevision, selection, problems } |
Rules:
cs.baseRevision !== contentRevisionproduces the error codestale.applydispatches a new reducer command,applyChangeSet. That command produces exactly one undo step, withorigin = cs.author.- At most one staged proposal exists. A new
proposereplaces the current one, and the old one resolves as'withdrawn'. - A staged proposal becomes stale on any content edit. Edits the user makes through undo or redo count too. It does not become stale on layout-only edits.
§3 Proposal Review UX
- Preview: while a proposal is staged,
preview = applyChangeSet(document, cs). The canvas renders the preview's elements plus ghosts of removed elements. Each element gets a diff status ofadded,modified,removedorunchanged. That status comes from a pure helper, extracted from the existingWorkflowDiffViewerdiff logic and shared with it. - Interaction: the canvas stays editable. Proposal elements can't be edited. Selecting one shows a read-only before/after view in the review bar.
- Review bar: a floating panel at the bottom of the canvas, clear of the canvas toolbar.
- It shows the summary, the author, and change counts.
- It shows a validation delta for
previewversus the current document, using built-in validation plusspi.validate: "introduces N / fixes M". - Accept runs the same path as
applyand resolves as'accepted'. Reject discards the proposal and resolves as'rejected'. - When the proposal is stale, the bar shows "Out of date" and only Dismiss is available.
- Highlight after apply: elements added or modified by the most recently applied change set (via
applyor Accept) keep a lingering highlight. It uses the same diff-status helper and the same styling as the review overlay. Removed elements get no ghost. - The highlight clears on the next user content edit, on
handle.clearHighlights(), or when another change set is applied. - It is enabled by default and can be disabled with the
highlightApplied={false}prop. - Events:
onProposalResolved(id, 'accepted' | 'rejected' | 'stale' | 'withdrawn'). 'stale'fires at the moment of invalidation.- On accept,
onChangefires once, with the agent origin. - Read-only mode:
proposerenders the preview without the review bar, which is useful for displaying a diff.applyreturns an error with the coderead-only.
Error Handling
- No handle method throws.
- Failed change sets return
{ status: 'rejected', error: { code, opIndex?, reason } }, which is suitable for sending back to the agent. - Host validation failures in the preview are reported the same way as the existing host validation failures.
Testing
Shared conformance fixtures, run by both the vitest and JUnit 5 suites:
conformance/changesets.json: every op type,unset, every error code, cascade, rename rewrite, and ordering.conformance/content-revision.json: hashes for representative workflows, including key-order permutations and number formatting edge cases.
Vitest unit tests on pure logic. No jsdom.
applyChangeSet:- each op type;
- missing targets and duplicate ids;
- cascade on
removeNode; - edge rewrite on
renameNode; - op ordering;
- atomicity.
contentRevision:- stable for identical content;
- unchanged by layout-only edits;
- restored by undo.
- Reducer:
applyChangeSetcreates a single undo step;- origin is recorded;
- stale, staged and withdrawn transitions are correct.
- Diff status helper, including parity with
WorkflowDiffViewerbehaviour. - Validation delta computation.
placeNewNodes: only positionless nodes move, and the full-layout fallback applies when no node has a position.- Highlight lifecycle: set on apply, cleared on user edit, on
clearHighlightsand on the next apply. onSelectionChange: fires only on an actual change.- Java: JUnit 5 tests for
ChangeSetsandContentRevisionthat drive the conformance fixtures. - Manual verification in the dev app (via Chrome DevTools MCP), with demo controls that propose or apply a canned change set.