Slice-1 of the Neural Link mutation-undo substrate (#13221) shipped the in-heap TransactionService undo stack + the undo tool: an agent can say "undo that" and the last committed mutation reverts cleanly under live enforcement. The converged Slice design names the next step explicitly — src/ai/TransactionService.mjs:71-72:
"the undo Neural Link tool, redo + named batching (Slice-2), Memory-Core persistence (Slice-3)…"
This ticket delivers the redo half of Slice-2 (single-level, symmetric to Slice-1's single-level undo). Named transaction batching (begin_transaction / commit_transaction) is the sibling Slice-2 leaf, filed separately. A conversational UI where a user can undo an agent's change naturally needs "redo that" / "actually, put it back" — redo is the immediate undo-pair the #9848 roadmap lists first among follow-ons.
The Problem
Today TransactionService.undo({id}) (src/ai/TransactionService.mjs:263-275) pops the newest committed transaction, marks it undone, and returns its reverse-ops for the caller to re-dispatch — but the popped transaction is discarded from session state. No retained branch exists to re-apply, so an agent cannot redo. The stack is one-directional.
The Architectural Reality
Neo.ai.TransactionService (src/ai/TransactionService.mjs) is the in-heap, per-(agentId,sessionId) undo authority. Session state is {open, committed[]} (TransactionService.mjs:108-114). It is pure over its inputs + unit-testable with no live-heap / socket dependency (the design's explicit testability seam) — so this whole slice's logic ships green with no live :8081 bridge.
The undo tool flows MCP → app: ai/mcp/server/neural-link/openapi.yaml (path + tier + schema) → ai/mcp/server/neural-link/toolService.mjs (serviceMapping) → the NL service method → bridge → src/ai/Client.mjs (in-app dispatch) → re-dispatched reverse-ops re-enforced as the current requester. redo mirrors this path exactly.
Compliance is contract-locked: test/playwright/unit/ai/mcp/validation/OpenApiValidatorCompliance.spec.mjs asserts expectedNeuralLinkToolTiers (toEqual, REQUIRED) — a new tool absent from the fixture fails the ai/mcp/validation scope.
The Fix
1. TransactionService — add a redo branch (symmetric to undo):
Add redo: [] to each session entry (alongside open, committed).
undo: instead of discarding the popped tx, push it onto redo (retain the undone branch). Status → undone.
New redo({id}): pop the newest from redo, set status → committed, push back onto committed, and return its forward-ops in capture order (tx.ops.map(forward)) for the caller to re-apply — re-dispatched + re-enforced as the current requester, with the enforcement subtreePath re-derived on the live tree (NOT the stored audit path), exactly as undo does. Returns {txId:null, forwardOps:null} when there is nothing to redo.
Divergence invalidation: committing a new transaction clears the redo stack — a fresh mutation diverges history, so the redo branch is no longer valid (standard editor semantics). sweep already drops the whole session (covers redo). abort / timeout touch only the open tx; they leave redo intact (an aborted open tx is not a divergence).
The undo/redo cycle is closed: undo→redo→undo re-uses the same retained tx, status cycling committed ↔ undone.
2. The redo NL tool (mirror undo, the 6-site wiring):
openapi.yaml: new redo path + tier (write-class, same tier as undo) + response schema ({redone, txId, reason}).
toolService.mjs: serviceMapping entry.
The NL service method + src/ai/Client.mjs dispatch: pull forwardOps from TransactionService.redo, re-dispatch each as a validated tool call under live enforcement (mirror the undo handler).
OpenApiValidatorCompliance.spec.mjs: add redo to expectedNeuralLinkToolTiers (+ the appropriate write-class set).
Unit spec sets the NL config autoConnect=falsebefore the import (the NL-service import-spawns-a-bridge trap).
3. Tests (no live bridge): TransactionService redo unit tests (redo after undo re-applies forward; redo with empty stack → null; a new commit clears the redo stack; the undo→redo→undo cycle; sweep clears redo) + a Client redo-dispatch wiring unit test + the compliance-fixture update.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
redo NL tool (NEW)
TransactionService.mjs:71-72 (Slice-2 = redo); merged undo tool (#13257) as mirror
Single-level redo of the last undone tx; re-applies forward-ops under live enforcement as the current requester
TransactionService redo unit tests + Client wiring unit test + OpenApiValidatorCompliance fixture
TransactionService.redo({id}) (NEW method)
TransactionService.mjs undo (symmetric)
Pop redo stack → forwardOps in capture order; status undone→committed
{txId:null, forwardOps:null}
class JSDoc (Anchor & Echo)
TransactionService.spec.mjs
TransactionService.commit (MODIFIED)
TransactionService.mjs:227-251
Also clears the session's redo stack (divergence invalidation)
unchanged fail-closed
class JSDoc
TransactionService.spec.mjs (new divergence test)
Decision Record impact
aligned-with the converged undo Slice design (Discussion #13216 → #13221; the slice-naming in TransactionService.mjs:71-72). No ADR challenged or superseded. No new ADR required — a leaf slice within an already-converged design.
Acceptance Criteria
TransactionService.redo({id}) re-applies the last undone transaction: returns its forward-ops in capture order, restores it to the committed stack (status committed), and it is undoable again.
redo with an empty redo stack (or nothing undone) returns {txId:null, forwardOps:null} — fail-closed, no throw.
Committing a NEW transaction clears the session's redo stack (a post-undo divergence invalidates the redo branch).
undo retains the popped transaction on the redo stack (no longer discards it); the undo→redo→undo cycle re-uses it.
sweep clears the redo stack (assert the whole-session delete covers it).
The redo NL tool is wired through all 6 sites (openapi path+tier+schema, toolService serviceMapping, service method, Client dispatch, the OpenApiValidatorCompliance fixture, unit spec autoConnect=false) and re-dispatches forward-ops under live enforcement as the current requester.
Fail-closed on incomplete writer identity (no-writer-identity), mirroring undo.
(Post-merge / bridge-gated) a live create → undo → redo → assert-restored e2e — deferred to a follow-up like the AC10 undo e2e (#13286), since Neo CI does not run the e2e suite and a live run needs a fresh :8081 bridge.
Out of Scope
Named transaction batching (begin_transaction / commit_transaction tools) — the sibling Slice-2 leaf, filed separately.
Multi-level redo(n) — Slice-1/2 are single-level by the converged design.
Memory-Core persistence of the redo branch — Slice-3.
The live e2e — bridge-gated follow-up (see the last AC).
Avoided Traps
Not re-deriving the enforcement path from the stored targetSubtreePath — redo, like undo, re-derives the subtreePath on the live tree at redo time + re-enforces as the current requester (the stored path is audit metadata only; TransactionService.mjs:65-68).
Not leaving the redo stack live across a divergent commit (a classic redo bug — redoing onto a changed tree). Commit clears it.
Not adding a live-heap or socket dependency to TransactionService — the redo branch stays pure in-heap state, unit-testable via event sequences (the design's testability invariant).
Retrieval Hint: "TransactionService redo single-level Slice-2"; branch agent/<this-ticket>-nl-redo off the merged Slice-1 undo stack.
Release classification: boardless (Slice-2 feature on the undo substrate — not v13-release-blocking; post-Approve-asymptote per the §4 scope judgment).
Context
Slice-1 of the Neural Link mutation-undo substrate (#13221) shipped the in-heap
TransactionServiceundo stack + theundotool: an agent can say "undo that" and the last committed mutation reverts cleanly under live enforcement. The converged Slice design names the next step explicitly —src/ai/TransactionService.mjs:71-72:This ticket delivers the
redohalf of Slice-2 (single-level, symmetric to Slice-1's single-level undo). Named transaction batching (begin_transaction/commit_transaction) is the sibling Slice-2 leaf, filed separately. A conversational UI where a user can undo an agent's change naturally needs "redo that" / "actually, put it back" — redo is the immediate undo-pair the #9848 roadmap lists first among follow-ons.The Problem
Today
TransactionService.undo({id})(src/ai/TransactionService.mjs:263-275) pops the newest committed transaction, marks itundone, and returns its reverse-ops for the caller to re-dispatch — but the popped transaction is discarded from session state. No retained branch exists to re-apply, so an agent cannot redo. The stack is one-directional.The Architectural Reality
Neo.ai.TransactionService(src/ai/TransactionService.mjs) is the in-heap, per-(agentId,sessionId)undo authority. Session state is{open, committed[]}(TransactionService.mjs:108-114). It is pure over its inputs + unit-testable with no live-heap / socket dependency (the design's explicit testability seam) — so this whole slice's logic ships green with no live:8081bridge.undotool flows MCP → app:ai/mcp/server/neural-link/openapi.yaml(path + tier + schema) →ai/mcp/server/neural-link/toolService.mjs(serviceMapping) → the NL service method → bridge →src/ai/Client.mjs(in-app dispatch) → re-dispatched reverse-ops re-enforced as the current requester.redomirrors this path exactly.test/playwright/unit/ai/mcp/validation/OpenApiValidatorCompliance.spec.mjsassertsexpectedNeuralLinkToolTiers(toEqual, REQUIRED) — a new tool absent from the fixture fails theai/mcp/validationscope.The Fix
1.
TransactionService— add a redo branch (symmetric to undo):redo: []to each session entry (alongsideopen,committed).undo: instead of discarding the popped tx, push it ontoredo(retain the undone branch). Status →undone.redo({id}): pop the newest fromredo, set status →committed, push back ontocommitted, and return its forward-ops in capture order (tx.ops.map(forward)) for the caller to re-apply — re-dispatched + re-enforced as the current requester, with the enforcementsubtreePathre-derived on the live tree (NOT the stored audit path), exactly as undo does. Returns{txId:null, forwardOps:null}when there is nothing to redo.redostack — a fresh mutation diverges history, so the redo branch is no longer valid (standard editor semantics).sweepalready drops the whole session (covers redo).abort/timeouttouch only the open tx; they leaveredointact (an aborted open tx is not a divergence).committed ↔ undone.2. The
redoNL tool (mirrorundo, the 6-site wiring):openapi.yaml: newredopath + tier (write-class, same tier asundo) + response schema ({redone, txId, reason}).toolService.mjs: serviceMapping entry.src/ai/Client.mjsdispatch: pullforwardOpsfromTransactionService.redo, re-dispatch each as a validated tool call under live enforcement (mirror the undo handler).OpenApiValidatorCompliance.spec.mjs: addredotoexpectedNeuralLinkToolTiers(+ the appropriate write-class set).autoConnect=falsebefore the import (the NL-service import-spawns-a-bridge trap).3. Tests (no live bridge): TransactionService redo unit tests (redo after undo re-applies forward; redo with empty stack → null; a new commit clears the redo stack; the undo→redo→undo cycle; sweep clears redo) + a
Clientredo-dispatch wiring unit test + the compliance-fixture update.Contract Ledger Matrix
redoNL tool (NEW)TransactionService.mjs:71-72(Slice-2 = redo); mergedundotool (#13257) as mirror{redone:false, reason:'nothing-to-redo' | 'no-writer-identity'}(fail-closed, mirrors undo)ai/mcp/server/neural-link/openapi.yamlTransactionService.redo({id})(NEW method)TransactionService.mjsundo (symmetric){txId:null, forwardOps:null}TransactionService.commit(MODIFIED)TransactionService.mjs:227-251Decision Record impact
aligned-withthe converged undo Slice design (Discussion #13216 → #13221; the slice-naming inTransactionService.mjs:71-72). No ADR challenged or superseded. No new ADR required — a leaf slice within an already-converged design.Acceptance Criteria
TransactionService.redo({id})re-applies the last undone transaction: returns its forward-ops in capture order, restores it to thecommittedstack (statuscommitted), and it is undoable again.redowith an empty redo stack (or nothing undone) returns{txId:null, forwardOps:null}— fail-closed, no throw.undoretains the popped transaction on the redo stack (no longer discards it); the undo→redo→undo cycle re-uses it.sweepclears the redo stack (assert the whole-session delete covers it).redoNL tool is wired through all 6 sites (openapi path+tier+schema, toolService serviceMapping, service method,Clientdispatch, theOpenApiValidatorCompliancefixture, unit specautoConnect=false) and re-dispatches forward-ops under live enforcement as the current requester.no-writer-identity), mirroring undo.create → undo → redo → assert-restorede2e — deferred to a follow-up like the AC10 undo e2e (#13286), since Neo CI does not run the e2e suite and a live run needs a fresh:8081bridge.Out of Scope
begin_transaction/commit_transactiontools) — the sibling Slice-2 leaf, filed separately.Avoided Traps
targetSubtreePath— redo, like undo, re-derives thesubtreePathon the live tree at redo time + re-enforces as the current requester (the stored path is audit metadata only; TransactionService.mjs:65-68).Related
Origin Session ID: a0bc6b23-78c5-4bd7-b944-9db5e236f42d
Retrieval Hint: "TransactionService redo single-level Slice-2"; branch
agent/<this-ticket>-nl-redooff the merged Slice-1 undo stack.Release classification: boardless (Slice-2 feature on the undo substrate — not v13-release-blocking; post-Approve-asymptote per the §4 scope judgment).