2026-08-25 · 5 min read
Two packages that do not know each other
An AI document editor assembled from a planner and a faker, neither of which has heard of the other. What that buys, and the bug it exposed.
Doc Studio in Python is about three hundred lines of application code. Everything interesting is in two packages it composes: ai-object decides what changes, phantom-core supplies the prose. Neither imports the other. The app is the seam.
message ──> ai_object.plan_edits ──> ops
│
GeneratedText markers ┴──> phantom_core.simulate ──> text
│
└──> store.apply ──> eventsThe planner never writes prose
That is the design decision everything else follows from. When the planner decides a new paragraph is needed, it does not write the paragraph — it emits a block whose text is a *request*: "Write one concise paragraph about caching for a document."
So the planner is pure. No network, no keys, no model, no randomness. Which means it can be tested exhaustively, and — as it turned out — held byte-identical to its TypeScript twin across sixty vectors covering every branch it has.
Resolution happens one layer up, and the store receives plain strings. It has never heard of an AI engine. Swapping phantom-core for a real provider is a change to one function.
The bug determinism caught
Because the whole turn is offline and deterministic, a test can assert something unusually strong: draft a document from the same brief twice, and the prose is identical.
That test failed. I had seeded generation with a string containing the document id — which is a random uuid — so every draft produced different text. It looked fine. Every document read plausibly. Nothing crashed. I had simply thrown away the one property phantom-core exists to provide, in the one line where it was easiest to do so.
Determinism is not a feature you can partially have. It is a property that a single careless seed removes, invisibly, everywhere downstream.
The fix was to seed by the prompt alone — which is what the TypeScript engine does. The prompt already differs per block, so it gives distinct text within a document and reproducibility across them.
Ordering as a real number
One small thing worth stealing. Block order is a float, not an integer. Moving a block between two neighbours means taking the midpoint of their orders — no renumbering, no cascade of writes, no chance of two siblings colliding on an index.
There is a test asserting the untouched siblings keep their original order values after a move, because the moment that stops being true, a move becomes an O(n) write and a source of races.
Deleting is a subtree operation
The other place a document quietly corrupts: deleting a section without its paragraphs. The blocks are still in the store, parented to something that no longer exists, so they vanish from the outline while continuing to exist — invisible, uneditable, and counted.
Both of those are the kind of bug that never throws. They just make the document slightly wrong in a way nobody can explain three weeks later, which is why they get tests rather than comments.
Why the substrate, not the app
The obvious way to mirror the React apps in Python was to pick one and port it. The better move was to port what fifteen of them share. Doc Studio then cost a few hundred lines — and CV Studio, Deck Studio and Table Studio are the same few hundred lines with a different container noun, an item noun, and a handful of words a user might use for each.
That is what a substrate is for, and it is only worth having when the second and third things built on it are cheap. This one now has to prove that twice more.