You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Status: pending spike confirmation. The mechanism this PRD assumes — that the MCP can
import and type-check against upstream's wab operations layer — is proven or disproven by #403. A no-go there changes the Implementation Decisions section only; everything else holds.
Blocked on PR #313 landing.
Problem Statement
When an agent drives the Plasmic MCP to build or edit a project, it can silently corrupt the
project bundle. The failure is not loud at the point of damage. The MCP mutates the in-memory
model first and only validates at save time, so the sequence is: mutate, then discover, then
throw — with the model already broken. The agent sees a save failure that names neither what it
did wrong nor how to recover, and the project can be left needing manual repair in Studio before
any further write succeeds.
The damage class is dangling references. Deleting a subtree that another component still
references through an implicit state or a tpl reference leaves the reference pointing at nothing.
Inserting an element somewhere the model cannot represent it produces a tree that renders
differently from what it describes. Neither is detected until the whole site is validated on the
way to the server.
A second, quieter version of the same problem is silent wrongness. Some operations succeed, save
cleanly, and produce a model that does not render as described — children attached to a void
element, list items outside a list, an element added to a node whose content is driven by an
expression that will overwrite it. An agent cannot see this divergence, so it builds on top of it
and compounds the error.
Underneath both is a maintenance problem. The MCP hand-rolls its own version of mutation logic
that Studio already implements — tree surgery, implicit-state cleanup, slot resolution, variant
handling — and has to keep that reimplementation correct against a model it does not own. Every
divergence between our version and Studio's is a latent corruption bug, and every upstream change
to the model is one we have to notice and mirror by hand.
Solution
Stop reimplementing Studio's mutation logic and call it directly.
Upstream extracted Studio's mutation operations into a public, dependency-injected layer while
building their own Copilot tools. The tools went into their private repository; the extracted
layer did not. It is a set of named operations, each with test coverage, none of which depend on
Studio's editor context for correctness. Each one validates before it mutates and returns a
structured success-or-error result rather than throwing, and its documented contract is that a
failed check leaves the model untouched.
Adopting it inverts the failure sequence. Instead of mutate → discover → throw, the MCP does
check → refuse → explain, with the model clean. The agent gets a named reason and, where one
exists, a named remedy it can call. Damage that used to require manual repair in Studio becomes a
message the agent can act on.
This also removes a large amount of code we currently own and must keep correct, and replaces it
with code that carries upstream's test suite and is exercised by Studio itself on every use.
The migration is incremental and gated. We freeze current behaviour as a measured baseline, move
one tool at a time, and triage every newly-refused input before moving to the next. Nothing is
migrated wholesale, and no capability is dropped without an explicit, recorded decision.
Because the same operations run unchanged in both a headless process and inside Studio, doing
this correctly also positions the MCP tools to run inside the Studio experience later, without a
second implementation.
User Stories
Agents driving the MCP
As an agent editing a Plasmic project, I want a destructive operation to be refused before it
runs when it would leave a dangling reference, so that I do not corrupt the project.
As an agent, I want the refusal to name the specific element holding the blocking reference,
so that I can resolve it rather than guess.
As an agent, I want the refusal to arrive from the tool I called rather than from a later save,
so that I can attribute the failure to the right action.
As an agent, I want the model to be unchanged after a refused operation, so that my next call
starts from a known-good state.
As an agent, I want a refusal that has a defined remedy to tell me the remedy, so that I can
complete the task instead of abandoning it.
As an agent, I want to be refused when I would attach children to an element that cannot render
them, so that I do not build on top of a model that renders differently from what it describes.
As an agent, I want to be refused when I would place an element that references variables into
a context where those variables do not exist, so that I do not create an unresolvable binding.
As an agent, I want to be refused when I would create a cycle by moving an element into its own
descendant, so that I do not produce an unrenderable tree.
As an agent, I want refusal messages to be stable and machine-distinguishable, so that I can
handle categories of failure programmatically rather than by matching prose.
As an agent, I want operations that previously succeeded and produced valid models to keep
succeeding, so that migration does not silently break my existing workflows.
As an agent, I want to keep expressing insertions as explicit element trees with precise
props, styles, slots, component instances and variants, so that I do not lose the control
that makes the MCP useful for non-trivial work.
As an agent, I want cleanup that Studio performs automatically — emptied list containers,
column redistribution — to happen on my deletions too, so that the result matches what a
designer would have got.
Developers and designers using the project afterwards
As a designer, I want a project an agent has edited to open in Studio without repair, so that
I can continue working immediately.
As a designer, I want agent edits to follow the same structural rules my own edits follow, so
that the project stays consistent regardless of who edited it.
As a developer, I want a project that has been agent-edited to publish and render correctly,
so that model damage does not surface first in production.
MCP maintainers
As an MCP maintainer, I want validation logic to come from upstream rather than be
reimplemented here, so that it stays correct as the model evolves without my intervention.
As an MCP maintainer, I want to delete our hand-rolled tree surgery and implicit-state
cleanup, so that the most corruption-prone code we own stops being ours to get right.
As an MCP maintainer, I want each migrated tool to inherit upstream's test coverage, so that
regressions are caught by tests I did not have to write.
As an MCP maintainer, I want a measured behavioural baseline before any internals change, so
that I can prove what did and did not change rather than argue about it.
As an MCP maintainer, I want every newly-refused input classified before the next tool is
migrated, so that capability loss is a decision rather than an accident.
As an MCP maintainer, I want a single boundary module where upstream operations are imported,
so that upstream churn lands in one reviewable place.
As an MCP maintainer, I want the tool logic to hold no reference to sessions, API clients or
saving, so that the same logic can run in a second environment without being rewritten.
As an MCP maintainer, I want migration to proceed one tool at a time behind a gate, so that a
bad outcome costs one tool rather than the whole surface.
As an MCP maintainer, I want the public MCP tool names, arguments and result shapes to be
unchanged by this work, so that no consumer has to migrate alongside us.
As an MCP maintainer, I want refusals distinguished from crashes in the tool result, so that
a refusal is not reported as an internal error.
Future in-Studio execution
As a product owner, I want the same tool implementations to be runnable inside the Studio
experience later, so that we do not maintain two divergent versions of the same behaviour.
As a product owner, I want the headless capability preserved, so that the MCP keeps working
with no browser session open — the property upstream's own design cannot offer.
As an MCP maintainer, I want the environment-specific concerns isolated to a thin adapter, so
that adding the Studio environment is an adapter, not a refactor.
Release and operations
As a release manager, I want each migrated tool to ship independently, so that a problem can
be reverted without unwinding the whole migration.
As a release manager, I want the behavioural change to be visible in release notes, so that
newly-refused inputs are understood as intended rather than reported as regressions.
As an MCP maintainer, I want an explicit record of the corruption classes this does not
address, so that the work is not judged against a promise it never made.
Implementation Decisions
Target and scope of adoption
We adopt upstream's operations layer, not their Copilot tool framework. The framework's public
surface after the upstream merge is an empty tool registry, a dispatch bridge that returns
nothing, and type shapes. It validates tool arguments, not model integrity, and is out of scope.
We import the operations directly into the MCP process. We do not dispatch through Studio's
host-frame bridge, which would require an open Studio session and forfeit headless operation.
The public MCP tool surface does not change. Tool names, actions, argument schemas and result
shapes stay as they are. This is an internals migration.
Layering — the load-bearing rule
Three layers, each owning exactly one environment concern.
Operations layer (upstream): model mutation and validation. No environment.
Tool core (ours): the element-tree DSL, node resolution, registry enrichment, result
shaping. Pure over an injected context. No environment.
Environment adapter (ours): session acquisition, change recording, fixups, persistence,
batching. Headless today; a Studio adapter later.
A tool core function must not acquire a session, hold an API client, or perform a save. This is
enforced by review from the first commit. It is what makes the later in-Studio environment an
adapter rather than a rewrite, and it is expensive to retrofit.
Current tool functions violate this — they take an API client, resolve a session from module
state, construct their own tree manager and own the save. Correcting this is part of each
slice, not a separate cleanup.
Modules to build or modify
Operation context provider(new, deep). Builds the injected context the operations
require from an environment. Two implementations behind one interface: headless, and later
Studio. Encapsulates all knowledge of where the model comes from; the tool core sees only the
context. Testable against a synthetic site with no environment at all.
Upstream operations boundary(new, thin). The single module through which upstream
operations are imported. Gives the build carve-out one target and gives upstream churn one
place to land. Nothing else in the MCP imports the operations directly.
Refusal mapper(new, deep). Translates upstream's structured operation errors and
cannot-add reason variants into MCP-facing results: a stable machine-readable category, a human
message, the referencing element resolved to a node reference the agent can address, and where
one exists, the named remedy operation. This is the triage process expressed as code, and it is
the module that determines whether refusals are actionable or merely correct. Pure — no
environment, no model mutation.
Transaction adapter(extracted from existing code). Owns change recording, post-change
fixups, batch and micro-batch accumulation, and persistence. Largely exists already as a single
internal function; this work promotes it to the explicit environment-adapter boundary.
Node domain tool core(modified). The node tools are re-expressed as pure functions over
the injected context, delegating mutation to upstream operations and refusal shaping to the
refusal mapper. Retains our element-tree DSL, node resolution and registry enrichment — these
have no upstream equivalent, since the operations take already-constructed model nodes.
Sequencing
Slice 0: freeze the behavioural baseline. No production code changes.
Slice 1: node removal. Smallest slice that exercises the whole pipeline — boundary module,
context provider, refusal mapper, transaction adapter, eval parity, refusal triage — against
the corruption risk that motivates the work. The upstream deletion operation requires no editor
context, which keeps incidental complexity out of the first slice.
Slice 2: node addition. The larger prize and the larger surface: it brings in the element-tree
builder, slot resolution and registry enrichment simultaneously, and it is where the majority
of refusal triage will occur. Sequenced second deliberately, with the pattern already proven.
Further slices are scoped after slice 2 and are not committed to by this PRD. Roughly a third
of our mutation surface has an upstream counterpart; the remainder — mixins, themes, splits,
assets, data tokens, queries, rich text, global and screen variants, page metadata — has none
and stays ours indefinitely.
Refusal handling
Refusals are not overridden. There is no bypass flag. Every refusal upstream can produce is
either structurally impossible in the model, invalid markup that browsers will restructure, or
silently lossy at render — and a bypass would reintroduce precisely the failure mode this work
exists to remove.
Where upstream exports the remedy for a refusal, the MCP surfaces it as a named, callable next
step. Where it does not, the refusal stands and the reason is returned.
Every newly-refused input is classified before the next slice begins. A refusal with neither an
accepted rationale nor an exported remedy is a signal to stop and reconsider the slice.
Types and build
Types resolve against upstream's real operation signatures rather than hand-written ambient
stubs. Extending the existing stub file would add maintained code to a project whose purpose is
removing it, and would leave everything untyped — runtime safety with no compile-time safety.
The bundler's client-code exclusion gets a narrow allowlist for the operations directory, minus
the modules that depend on editor context, browser APIs or modal dialogs. This is a carve-out,
not a relocation of the boundary.
A good test asserts observable behaviour at a module's interface: given this model state and this
input, the result is success with these effects, or refusal with this category and this named
element. It does not assert which upstream function was called, in what order, or with what
arguments. Tests that pin our call sequence into upstream would defeat the purpose of the
migration — they would break on upstream refactors that change nothing we care about, and would
have to be rewritten every merge.
Refusal tests assert the category and the presence of an actionable referent, not the exact
prose. Message wording belongs to upstream and will change; the category and the referenced
element are our contract with the agent.
Every test that asserts a refusal must also assert that the model is unchanged. The refusal is
half the guarantee; the untouched model is the other half, and it is the half that was broken
before.
Modules to be tested
Refusal mapper — the highest-value target and fully pure. Every upstream refusal variant maps
to a stable category; those with remedies surface the remedy; those carrying a referencing
element resolve it to an addressable node reference; unrecognised variants degrade to a safe
generic refusal rather than crashing. Exhaustive unit coverage.
Operation context provider — builds a valid context from a synthetic site; the headless
implementation omits the optional editor-dependent callbacks and operations still behave
correctly without them.
Node domain tool core, per slice — behavioural coverage of each migrated tool: success paths
produce the expected model change; each refusal path returns the right category and leaves the
model untouched; the cleanup upstream performs and we previously did not is observable.
Transaction adapter — recording, fixups and batch accumulation behave as before. This is
primarily a no-regression boundary; existing coverage largely applies.
Upstream operations boundary — not unit tested directly. Its correctness is that it compiles
and that the bundle contains no editor-side imports, which is a build assertion.
Baseline and parity gate
Before any internals change, the existing evaluation suite is run against current behaviour and
its results frozen as the baseline. This is slice 0 and it produces no production code change.
The gate for each slice is parity against that baseline, plus new scenarios pinning any
capability we are specifically concerned about losing. Divergences are triaged, not waived.
Byte-identical bundle comparison is explicitly rejected as a gate. Upstream's operations set
variant settings and adopt container styles differently by design, so byte-identity would block
on cosmetic differences while telling us nothing about behaviour.
Newly-refused inputs are expected and are not automatic failures. Each must be shown, against
the baseline, to have previously produced a model that was invalid, unrenderable as described,
or silently lossy.
Prior art
Unit tests for the node domain already exist and follow the pattern this work extends —
per-domain test modules under the package's test directory, using the existing test runner.
The evaluation harness already provides scenario definitions, state checks, transcript checks,
visual capture and a reporter, including a node-domain scenario set. The baseline and parity
gate use this machinery as-is; no new harness is required.
Upstream ships spec coverage for nearly every operation in the layer we are adopting. We do not
duplicate it. Our tests cover our layers — the mapping, the context, the tool cores — and treat
the operations as trusted.
Out of Scope
The Copilot tool framework, its schema export and its in-Studio dispatch bridge.
Actually running the MCP tools inside Studio. This PRD makes it cheap by isolating environment
concerns to an adapter; building the Studio adapter is separate work and a separate product
decision.
Any tool whose domain has no upstream counterpart: mixins, themes, splits, assets and images,
data tokens, queries, rich text, attributes, visibility, data conditions and repetition, page
metadata, global variant groups, screen variants, page and component conversion. These stay
ours.
Retiring the hand-written ambient type stubs wholesale. That is the correct end state and would
remove a substantial body of maintained code, but the repository carries pre-existing type
errors and it is a project in its own right.
Registered-function and code-component signature drift — stale argument and state-parameter
references left when a registered signature changes underneath an existing binding. This
originates in the dev host, outside the model, and nothing in the operations layer addresses
it. It remains a save-time validation problem.
The five upstream operation modules that depend on editor context, browser APIs or modal
dialogs, including the HTML-to-tree importer. The importer may become a second front-end onto
the same operations later; it is additive and never a replacement for the element-tree DSL.
The upstream merge itself, and its deployment.
Further Notes
This will look like a regression before it looks like a fix. Inputs that previously
"succeeded" will start returning refusals. That is the intended outcome — each one was producing
a model that was invalid or that rendered differently from what it described — but it will be
reported as a regression by anyone reading only the symptom. The frozen baseline is what lets us
show, per case, that the previous success was not one. This should be stated plainly in release
notes rather than discovered.
Why the durability of this bet is better than it looks. Upstream's public and private
surfaces are separated mechanically, by a sync tool and lint rules that forbid public code from
importing private code. The operations layer sits on the public side of that boundary and is
structurally barred from acquiring private dependencies. If upstream ever did move it private, we
would inherit stubs on a merge — a loud break, not silent drift — with the last public version in
history and upstream's own spec files available to keep a fork honest. That is a considerably
better failure mode than the status quo, where the equivalent risk is a bundle corrupting in a
customer project.
A conflict-free extension point exists if we want it later. Because upstream keeps their tool
implementations private, the public tool registry arrives empty on every merge and will continue
to. If EP ever wants tools running inside Studio, that file can be filled without ever conflicting
with upstream. Worth recording in the upstream merge runbook's customization registry
independently of this work.
The element-tree DSL was never at risk. An earlier framing of this work suggested replacing
our insertion path with upstream's HTML importer. That concern does not apply: the operations
take already-constructed model nodes and have no notion of our element-tree format, so our DSL
sits above them untouched. The importer, if adopted, is a second front-end onto the same
back-end.
Module design is proposed, not confirmed. The four-module shape above — context provider,
operations boundary, refusal mapper, transaction adapter — reflects the design discussion but has
not been reviewed against implementation. The refusal mapper is the one to scrutinise: it is the
module that decides whether refusals are actionable or merely correct, and it is the most likely
to be under-specified here.
Problem Statement
When an agent drives the Plasmic MCP to build or edit a project, it can silently corrupt the
project bundle. The failure is not loud at the point of damage. The MCP mutates the in-memory
model first and only validates at save time, so the sequence is: mutate, then discover, then
throw — with the model already broken. The agent sees a save failure that names neither what it
did wrong nor how to recover, and the project can be left needing manual repair in Studio before
any further write succeeds.
The damage class is dangling references. Deleting a subtree that another component still
references through an implicit state or a tpl reference leaves the reference pointing at nothing.
Inserting an element somewhere the model cannot represent it produces a tree that renders
differently from what it describes. Neither is detected until the whole site is validated on the
way to the server.
A second, quieter version of the same problem is silent wrongness. Some operations succeed, save
cleanly, and produce a model that does not render as described — children attached to a void
element, list items outside a list, an element added to a node whose content is driven by an
expression that will overwrite it. An agent cannot see this divergence, so it builds on top of it
and compounds the error.
Underneath both is a maintenance problem. The MCP hand-rolls its own version of mutation logic
that Studio already implements — tree surgery, implicit-state cleanup, slot resolution, variant
handling — and has to keep that reimplementation correct against a model it does not own. Every
divergence between our version and Studio's is a latent corruption bug, and every upstream change
to the model is one we have to notice and mirror by hand.
Solution
Stop reimplementing Studio's mutation logic and call it directly.
Upstream extracted Studio's mutation operations into a public, dependency-injected layer while
building their own Copilot tools. The tools went into their private repository; the extracted
layer did not. It is a set of named operations, each with test coverage, none of which depend on
Studio's editor context for correctness. Each one validates before it mutates and returns a
structured success-or-error result rather than throwing, and its documented contract is that a
failed check leaves the model untouched.
Adopting it inverts the failure sequence. Instead of mutate → discover → throw, the MCP does
check → refuse → explain, with the model clean. The agent gets a named reason and, where one
exists, a named remedy it can call. Damage that used to require manual repair in Studio becomes a
message the agent can act on.
This also removes a large amount of code we currently own and must keep correct, and replaces it
with code that carries upstream's test suite and is exercised by Studio itself on every use.
The migration is incremental and gated. We freeze current behaviour as a measured baseline, move
one tool at a time, and triage every newly-refused input before moving to the next. Nothing is
migrated wholesale, and no capability is dropped without an explicit, recorded decision.
Because the same operations run unchanged in both a headless process and inside Studio, doing
this correctly also positions the MCP tools to run inside the Studio experience later, without a
second implementation.
User Stories
Agents driving the MCP
runs when it would leave a dangling reference, so that I do not corrupt the project.
so that I can resolve it rather than guess.
so that I can attribute the failure to the right action.
starts from a known-good state.
complete the task instead of abandoning it.
them, so that I do not build on top of a model that renders differently from what it describes.
a context where those variables do not exist, so that I do not create an unresolvable binding.
descendant, so that I do not produce an unrenderable tree.
handle categories of failure programmatically rather than by matching prose.
succeeding, so that migration does not silently break my existing workflows.
props, styles, slots, component instances and variants, so that I do not lose the control
that makes the MCP useful for non-trivial work.
column redistribution — to happen on my deletions too, so that the result matches what a
designer would have got.
Developers and designers using the project afterwards
I can continue working immediately.
that the project stays consistent regardless of who edited it.
so that model damage does not surface first in production.
MCP maintainers
reimplemented here, so that it stays correct as the model evolves without my intervention.
cleanup, so that the most corruption-prone code we own stops being ours to get right.
regressions are caught by tests I did not have to write.
that I can prove what did and did not change rather than argue about it.
migrated, so that capability loss is a decision rather than an accident.
so that upstream churn lands in one reviewable place.
saving, so that the same logic can run in a second environment without being rewritten.
bad outcome costs one tool rather than the whole surface.
unchanged by this work, so that no consumer has to migrate alongside us.
a refusal is not reported as an internal error.
Future in-Studio execution
experience later, so that we do not maintain two divergent versions of the same behaviour.
with no browser session open — the property upstream's own design cannot offer.
that adding the Studio environment is an adapter, not a refactor.
Release and operations
be reverted without unwinding the whole migration.
newly-refused inputs are understood as intended rather than reported as regressions.
address, so that the work is not judged against a promise it never made.
Implementation Decisions
Target and scope of adoption
surface after the upstream merge is an empty tool registry, a dispatch bridge that returns
nothing, and type shapes. It validates tool arguments, not model integrity, and is out of scope.
host-frame bridge, which would require an open Studio session and forfeit headless operation.
shapes stay as they are. This is an internals migration.
Layering — the load-bearing rule
shaping. Pure over an injected context. No environment.
batching. Headless today; a Studio adapter later.
enforced by review from the first commit. It is what makes the later in-Studio environment an
adapter rather than a rewrite, and it is expensive to retrofit.
state, construct their own tree manager and own the save. Correcting this is part of each
slice, not a separate cleanup.
Modules to build or modify
require from an environment. Two implementations behind one interface: headless, and later
Studio. Encapsulates all knowledge of where the model comes from; the tool core sees only the
context. Testable against a synthetic site with no environment at all.
operations are imported. Gives the build carve-out one target and gives upstream churn one
place to land. Nothing else in the MCP imports the operations directly.
cannot-add reason variants into MCP-facing results: a stable machine-readable category, a human
message, the referencing element resolved to a node reference the agent can address, and where
one exists, the named remedy operation. This is the triage process expressed as code, and it is
the module that determines whether refusals are actionable or merely correct. Pure — no
environment, no model mutation.
fixups, batch and micro-batch accumulation, and persistence. Largely exists already as a single
internal function; this work promotes it to the explicit environment-adapter boundary.
the injected context, delegating mutation to upstream operations and refusal shaping to the
refusal mapper. Retains our element-tree DSL, node resolution and registry enrichment — these
have no upstream equivalent, since the operations take already-constructed model nodes.
Sequencing
context provider, refusal mapper, transaction adapter, eval parity, refusal triage — against
the corruption risk that motivates the work. The upstream deletion operation requires no editor
context, which keeps incidental complexity out of the first slice.
builder, slot resolution and registry enrichment simultaneously, and it is where the majority
of refusal triage will occur. Sequenced second deliberately, with the pattern already proven.
of our mutation surface has an upstream counterpart; the remainder — mixins, themes, splits,
assets, data tokens, queries, rich text, global and screen variants, page metadata — has none
and stays ours indefinitely.
Refusal handling
either structurally impossible in the model, invalid markup that browsers will restructure, or
silently lossy at render — and a bypass would reintroduce precisely the failure mode this work
exists to remove.
step. Where it does not, the refusal stands and the reason is returned.
accepted rationale nor an exported remedy is a signal to stop and reconsider the slice.
Types and build
stubs. Extending the existing stub file would add maintained code to a project whose purpose is
removing it, and would leave everything untyped — runtime safety with no compile-time safety.
the modules that depend on editor context, browser APIs or modal dialogs. This is a carve-out,
not a relocation of the boundary.
Testing Decisions
What makes a good test here
A good test asserts observable behaviour at a module's interface: given this model state and this
input, the result is success with these effects, or refusal with this category and this named
element. It does not assert which upstream function was called, in what order, or with what
arguments. Tests that pin our call sequence into upstream would defeat the purpose of the
migration — they would break on upstream refactors that change nothing we care about, and would
have to be rewritten every merge.
Refusal tests assert the category and the presence of an actionable referent, not the exact
prose. Message wording belongs to upstream and will change; the category and the referenced
element are our contract with the agent.
Every test that asserts a refusal must also assert that the model is unchanged. The refusal is
half the guarantee; the untouched model is the other half, and it is the half that was broken
before.
Modules to be tested
to a stable category; those with remedies surface the remedy; those carrying a referencing
element resolve it to an addressable node reference; unrecognised variants degrade to a safe
generic refusal rather than crashing. Exhaustive unit coverage.
implementation omits the optional editor-dependent callbacks and operations still behave
correctly without them.
produce the expected model change; each refusal path returns the right category and leaves the
model untouched; the cleanup upstream performs and we previously did not is observable.
primarily a no-regression boundary; existing coverage largely applies.
and that the bundle contains no editor-side imports, which is a build assertion.
Baseline and parity gate
its results frozen as the baseline. This is slice 0 and it produces no production code change.
capability we are specifically concerned about losing. Divergences are triaged, not waived.
variant settings and adopt container styles differently by design, so byte-identity would block
on cosmetic differences while telling us nothing about behaviour.
the baseline, to have previously produced a model that was invalid, unrenderable as described,
or silently lossy.
Prior art
per-domain test modules under the package's test directory, using the existing test runner.
visual capture and a reporter, including a node-domain scenario set. The baseline and parity
gate use this machinery as-is; no new harness is required.
duplicate it. Our tests cover our layers — the mapping, the context, the tool cores — and treat
the operations as trusted.
Out of Scope
concerns to an adapter; building the Studio adapter is separate work and a separate product
decision.
data tokens, queries, rich text, attributes, visibility, data conditions and repetition, page
metadata, global variant groups, screen variants, page and component conversion. These stay
ours.
remove a substantial body of maintained code, but the repository carries pre-existing type
errors and it is a project in its own right.
references left when a registered signature changes underneath an existing binding. This
originates in the dev host, outside the model, and nothing in the operations layer addresses
it. It remains a save-time validation problem.
dialogs, including the HTML-to-tree importer. The importer may become a second front-end onto
the same operations later; it is additive and never a replacement for the element-tree DSL.
Further Notes
This will look like a regression before it looks like a fix. Inputs that previously
"succeeded" will start returning refusals. That is the intended outcome — each one was producing
a model that was invalid or that rendered differently from what it described — but it will be
reported as a regression by anyone reading only the symptom. The frozen baseline is what lets us
show, per case, that the previous success was not one. This should be stated plainly in release
notes rather than discovered.
Why the durability of this bet is better than it looks. Upstream's public and private
surfaces are separated mechanically, by a sync tool and lint rules that forbid public code from
importing private code. The operations layer sits on the public side of that boundary and is
structurally barred from acquiring private dependencies. If upstream ever did move it private, we
would inherit stubs on a merge — a loud break, not silent drift — with the last public version in
history and upstream's own spec files available to keep a fork honest. That is a considerably
better failure mode than the status quo, where the equivalent risk is a bundle corrupting in a
customer project.
A conflict-free extension point exists if we want it later. Because upstream keeps their tool
implementations private, the public tool registry arrives empty on every merge and will continue
to. If EP ever wants tools running inside Studio, that file can be filled without ever conflicting
with upstream. Worth recording in the upstream merge runbook's customization registry
independently of this work.
The element-tree DSL was never at risk. An earlier framing of this work suggested replacing
our insertion path with upstream's HTML importer. That concern does not apply: the operations
take already-constructed model nodes and have no notion of our element-tree format, so our DSL
sits above them untouched. The importer, if adopted, is a second front-end onto the same
back-end.
Module design is proposed, not confirmed. The four-module shape above — context provider,
operations boundary, refusal mapper, transaction adapter — reflects the design discussion but has
not been reviewed against implementation. The refusal mapper is the one to scrutinise: it is the
module that decides whether refusals are actionable or merely correct, and it is the most likely
to be under-specified here.