Context
Surfaced by #13306's redo e2e (test/playwright/e2e/NeuralLinkRedo.spec.mjs, branch fix/13306-nl-redo-e2e): a create_instance → undo → redo round-trip over the live bridge. Create + undo pass; redo fails:
{"redone":false,"reason":"redo-denied: create_instance: provide `className` or `ntype` to instantiate."}So redo-of-a-created-component is non-functional: the re-dispatched forward-op has no ntype/className. (Undo works — this is redo-specific. The unit specs mock the transport, so they never exercised the live re-dispatch shape; the e2e did.)
Root cause (source-verified)
In src/ai/client/InstanceService.mjs#createInstance (~:92):
:94 createConfig = buildCreateInstanceConfig(...) — folds the resolved ntype/className INTO createConfig (e.g. {id, text, ntype: 'button'}).
:109 Neo.ntype(createConfig) → Neo.create(className, createConfig) → instance.construct(createConfig) — instantiation consumes the meta keys (ntype/className) off createConfig.
:126 the reverse is built from that same, now-mutated createConfig: buildCreateInstanceReverse({config: createConfig, ...}) → :286 forwardArgs = {config: safeSerialize(config)}.
safeSerialize preserves all object keys (it does NOT strip ntype), so the loss is upstream: createConfig no longer carries ntype/className by the time it's captured. On redo, handleRequest('create_instance', {config: {…no ntype…}, parentId}) → buildCreateInstanceConfig rejects with "provide className or ntype." The only mutation between the build (:94) and the capture (:126) is the instantiation at :109 — hence the cause.
The Fix
Snapshot the resolved config before instantiation consumes it, and capture from the snapshot:
const
createConfig = this.buildCreateInstanceConfig({className, config, ntype}),
reverseConfig = {...createConfig};
const instance = createConfig.ntype ? Neo.ntype(createConfig) : Neo.create(createConfig);
this.recordUndo(context, this.buildCreateInstanceReverse({config: reverseConfig, }));Low-blast (capture-only; the create path itself is unchanged) and behavior-restoring (the redo contract — re-apply the undone create — is already documented; this fixes the broken implementation, so no new Contract Ledger per the contract-ledger.md bug-fix-restoring-documented-contract exclusion).
Acceptance Criteria
Related
- Blocks #13306 (the redo e2e proof — built, red on this bug; goes green with the fix).
- Pillar-2 undo/redo: Slice-parent #9848; epic #13012; sibling Slice-1 #13221 (undo, shipped).
set_instance_properties redo is unaffected (fully cyclic); this is the create-replay leg.
Release classification: post-release Pillar-2 bug-fix (not v13-release-blocking) → boardless.
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-vega (Vega).
Context
Surfaced by #13306's redo e2e (
test/playwright/e2e/NeuralLinkRedo.spec.mjs, branchfix/13306-nl-redo-e2e): acreate_instance → undo → redoround-trip over the live bridge. Create + undo pass; redo fails:{"redone":false,"reason":"redo-denied: create_instance: provide `className` or `ntype` to instantiate."}So redo-of-a-created-component is non-functional: the re-dispatched forward-op has no
ntype/className. (Undo works — this is redo-specific. The unit specs mock the transport, so they never exercised the live re-dispatch shape; the e2e did.)Root cause (source-verified)
In
src/ai/client/InstanceService.mjs#createInstance(~:92)::94createConfig = buildCreateInstanceConfig(...)— folds the resolvedntype/classNameINTOcreateConfig(e.g.{id, text, ntype: 'button'}).:109Neo.ntype(createConfig)→Neo.create(className, createConfig)→instance.construct(createConfig)— instantiation consumes the meta keys (ntype/className) offcreateConfig.:126the reverse is built from that same, now-mutatedcreateConfig:buildCreateInstanceReverse({config: createConfig, ...})→:286forwardArgs = {config: safeSerialize(config)}.safeSerializepreserves all object keys (it does NOT stripntype), so the loss is upstream:createConfigno longer carriesntype/classNameby the time it's captured. On redo,handleRequest('create_instance', {config: {…no ntype…}, parentId})→buildCreateInstanceConfigrejects with "provideclassNameorntype." The only mutation between the build (:94) and the capture (:126) is the instantiation at:109— hence the cause.The Fix
Snapshot the resolved config before instantiation consumes it, and capture from the snapshot:
const createConfig = this.buildCreateInstanceConfig({className, config, ntype}), reverseConfig = {...createConfig}; // ntype/className intact, pre-instantiation // … const instance = createConfig.ntype ? Neo.ntype(createConfig) : Neo.create(createConfig); // … this.recordUndo(context, this.buildCreateInstanceReverse({config: reverseConfig, /* … */}));Low-blast (capture-only; the create path itself is unchanged) and behavior-restoring (the redo contract — re-apply the undone create — is already documented; this fixes the broken implementation, so no new Contract Ledger per the contract-ledger.md bug-fix-restoring-documented-contract exclusion).
Acceptance Criteria
redoof acreate_instancerestores the component to the live tree + DOM (theNeuralLinkRedo.spece2e goes green).create_instancereverse forward-op retainsntype/className(a regression lock at the capture layer, not just e2e).InstanceServiceUndo*/ capture specs.Related
set_instance_propertiesredo is unaffected (fully cyclic); this is the create-replay leg.Release classification: post-release Pillar-2 bug-fix (not v13-release-blocking) → boardless.
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-vega (Vega).