Operand

pro bo? no.

gram: docs

> ./openspec/changes/engine-spine-tier2/SPIKE.md

# Spike: is `DocxEditorEngine` the right abstraction?
Run after the eng-review + outside voice challenged the premise (class vs pure-function lift). Two questions, answered against the real code.
## Q1 — What is `run()`'s real input contract? Push or pull?
`runLayoutPipeline`'s dep array (`useLayoutPipeline.ts:532-559`) is ~24 values. Classified:
**Derivable inside `run` from `document` (NOT irreducible inputs):**
- `pageSize, margins, finalPageSize, finalMargins``getPageSize`/`getMargins(sectionProperties)`**already core** (`layout-bridge/sectionGeometry.ts:28,44`).
- `columns, finalColumns``getColumns(...)` — React-local pure fn (`internals/columnLayout.ts:26`), lifts trivially.
- `contentWidth` ← derived from pageSize − margins.
- `sectionProperties, finalSectionProperties` ← from `document`.
- `headerContent, …firstPageFooterContent``resolveHeaderFooter` + `convertHeaderFooter*` — already core (HF resolve is shared).
- `styles, theme` ← from `document`.
**Genuine host seams (~6):** `zoom`, `getScrollContainer`, the host elements (pages/viewport/body), `resolvedCommentIds` (React-only; Vue empty), `getHfPmView`/`getHfPmDoc`, plus the already-core `syncCoordinator` (LayoutSelectionGate).
**Verdict:** the outside voice's "run needs ~25 inputs" is true by COUNT but ~18 are derived-from-`document`. The real contract is `run(state)` + pull `document` + ~6 host getters. **The input explosion is not a blocker.** Caveat that IS real: both adapters must stop computing geometry their own way (React upstream, Vue inline at `useDocxEditor.ts:314-327`) and delegate geometry resolution to the engine. That's a bounded change touching both adapters, and it requires `getColumns` → core. Decision 4 ("push-based, never reads framework state") needs rewording: the engine _pulls_ editor inputs (`document`, `zoom`, host els) via host getters — those are editor state exposed by the host, not React/Vue reactive primitives. Mild softening, defensible.
## Q2 — Does `engine.load`/`save` lift cleanly against React's actual structure?
No — and this is where the outside voice was most right. React's load is NOT a unit; it's `useHistory` as source-of-truth + four reactive effects:
- `loadBuffer`: `parseDocx``history.reset(doc)` (`useDocumentLoader.ts:73-90`)
- body view recreate: a `useEffect` on the document prop (`HiddenProseMirror.tsx:398-421`)
- agent build: a `useEffect` on `history.state` (`useDocumentLoader.ts:110-116`)
- comment extract + `seedCommentAllocator`: another `useEffect` (`:122-147`)
`engine.save` reads `agentRef` (bound to React `history.state`) + the `comments` React `useState` array + `documentName` (`useFileIO.ts:59-120`).
**Verdict:** owning load/save in the engine is a **source-of-truth inversion** (engine owns the document lifecycle; React's `useHistory`/agent/comments become subscribers or host callbacks), not a sequence lift. Coherent but materially bigger than the plan stated. This is where the class earns or loses.
## Recommendation: refined scope (the natural seam)
The two halves have very different risk:
- **Layout + scheduler + transaction loop = CLEAN.** `run` lifts well (inputs derive from `document`; geometry helpers already core). The scheduler and transaction handler are small and self-contained. This is ~70% of the dedup value and the lower-risk half.
- **View lifecycle + load/save = ENTANGLED.** Per-rId views both adapters trigger reactively; load/save are a source-of-truth inversion bound to React's history/useState.
So regardless of the class-vs-function decision, **the right first move is the same**: lift the layout/scheduler/transaction-loop into the engine; keep view lifecycle + session (load/save) adapter-side for now. The plan's own fallback note already permits this ("engine owns layout+scheduler+loop while views/session stay adapter-side").
- If **#89 (vanilla) is wanted** → continue to the engine class, and treat load/save as a deliberate source-of-truth inversion step (rescoped, not a "lift the sequence" step). View lifecycle next.
- If **#89 is not near-term** → the engine can stop at layout+scheduler+loop; `run` can even be a pure function the adapters call (the class is only needed to _own_ views + session). Most of the dedup, far less risk.
**This is decidable empirically AFTER the clean half ships:** build the engine for layout+scheduler+loop first (low risk, high value), see how the `EngineHost` contract feels in practice, then decide whether to absorb views+session (the class payoff for #89) or stop. The `#89` priority question resolves itself once we see whether owning session is worth the inversion.
## Plan adjustments from the spike
1. Reword design Decision 4: engine _pulls_ `document`/`zoom`/host-els via host getters; never touches framework reactive primitives.
2. Add to step 1: move `getColumns` to core; both adapters delegate geometry resolution to the engine (React stops computing it upstream).
3. Rescope step 5 (load/save): it is a source-of-truth inversion (engine owns document lifecycle; adapter history/agent/comments become subscribers), gated on the #89 decision. Until then the engine may stop at layout+scheduler+loop+views.
4. Re-sequence option: ship layout+scheduler+loop (steps 1-3) as the committed core; treat views (4) and session (5) as a second decision point informed by how the contract feels.