Summary
transform_mode is a runtime toggle over the same database and the same session, so the Rust module and the TypeScript plugin are two implementations of one contract. A slice-by-slice comparison found 12 places where they produce different observable behaviour for the same input.
Each item below was verified by opening both sides. Where I could construct the concrete divergent input, it is given. Findings I could not substantiate were dropped — see "What I checked and rejected" at the end, because the rejections are informative about where the boundaries actually are.
The meta-finding is at the bottom and may matter more than any single item: the differential goldens pass, and several of these divergences sit in cases no golden exercises. A passing cargo test does not currently imply parity.
Class A — wire-byte divergences (prompt cache busts on mode flip)
A1 — Legacy truncation counts UTF-16 units in TS, Unicode scalars in Rust
- TS
packages/plugin/src/hooks/magic-context/decay-render.ts:104 — content.slice(0, 420) / content.slice(0, 1_200). String.prototype.slice indexes UTF-16 code units.
- Rust
crates/mc-module/src/decay_render.rs:186 — content.chars().take(max); counts Unicode scalar values.
Input "U:\n" + "a".repeat(416) + "😀b" — 421 scalars, 422 UTF-16 units:
|
cut at |
result |
| TS |
unit 420 |
splits the emoji, emits an unpaired high surrogate |
| Rust |
scalar 420 |
emoji intact |
Different m[0] bytes for the same legacy compartment. The Rust docstring already acknowledges this ("Char-boundary safe (vs the TS UTF-16 slice…)"), so the divergence is known at the source — it just isn't reconciled or covered by a golden. crates/mc-core/testdata/decay-golden.json contains zero non-BMP characters and never exercises the 420/1200 boundary.
A2 — Reasoning sentinels drop cache metadata and preserve the original type
- TS
packages/plugin/src/hooks/magic-context/sentinel.ts:105-121 — makeSentinel forces type: "text" and copies cache_control / cacheControl from the original part.
- Rust
crates/mc-module/src/transform.rs:13037-13052 — empty_reasoning_sentinel emits only type (preserved from the original, defaulting to "reasoning") plus thinking or text. No cache metadata copy.
Clearing {type:"reasoning", text:"…", cache_control:{type:"ephemeral"}} gives TS {type:"text", text:"", cache_control:{…}} and Rust {type:"reasoning", text:""}. Three differences: the type field, the key, and the dropped cache-control boundary. This one is doubly relevant because it changes where a provider cache breakpoint lands.
A3 — Edit-marker canonicalises JSON key order; TS preserves insertion order
- TS
packages/plugin/src/hooks/magic-context/edit-marker.ts:53-62 — applyEditMarkerToInput mutates the input object in place; JS preserves insertion order on serialise.
- Rust
crates/mc-module/src/selection.rs:609-624 — canonical_json sorts object keys ("deterministic bytes across passes").
{"z":…,"filePath":…,"content":…} serialises as z,filePath,content under TS and content,filePath,z under Rust.
Caveat, stated because it is the exact trap that killed one of my other candidates: I confirmed the canonicalisation at selection.rs, but did not prove the canonicalised payload reaches the wire rather than remaining a frozen store artifact that is re-rendered later. If the render path re-serialises through the host's own encoder, this is a non-issue. Worth a maintainer's five seconds before anyone acts on it.
Class B — decision divergences (same input, different fold/reclaim outcome)
B1 — Completed tool-arc fencing moves in opposite directions
- TS
packages/plugin/src/hooks/magic-context/protected-tail-boundary.ts:371-372 — end = Math.min(protectedTailStart, resOrdinal + 1). Always forward: extend to include the whole arc.
- Rust
crates/mc-module/src/boundary.rs, fence_boundary_for_completed_tool_arcs — if min_invocation < floor { max_result + 1 } else { min_invocation }. Forward only when the invocation is below the publication floor; otherwise backward, excluding the entire arc.
Both honour arc atomicity, but they resolve the same crossing in opposite directions. With offset=1, protected_tail_start=5, a completed arc at inv=2/res=4 and a head-cap end of 3: TS yields 1..5 (arc retained), Rust yields 1..2 (arc excluded). The historian receives a different chunk, so different compartments are durably persisted depending on which mode was active.
B2 — Reclaim can rewrite an in-flight tool call
- TS
packages/plugin/src/hooks/magic-context/tool-drop-target.ts:284-291 — partHasCompletedResult requires state.output to be a string or status === "error". The three reclaim methods (:386, :399, :414) each return "incomplete" when !entry.hasResult.
- Rust
crates/mc-module/src/selection.rs:707-728 — expand_arc iterates arc.call_inputs and emits a ReductionDecision for each call block before it touches result_ids. group_arcs (:329) initialises result_ids: Vec::new(), the active_arcs filter checks only !reduced && !provider_executed && !reasoning_ineligible, and the sole result_ids.is_empty() guard in the file (:833) is scoped to select_tool_dedup.
So an arc with call inputs and no result yet still yields a decision that rewrites the call block. TS explicitly refuses. I'd rank this the most serious item here: it is a correctness problem in its own right, not merely a mode-flip inconsistency. I have not constructed a live repro proving such an arc reaches selection in practice — but TS guards against it in three separate places, which is reasonable evidence that it is reachable.
B3 — Caveman eligibility is narrower in Rust
- TS
packages/plugin/src/hooks/magic-context/caveman-cleanup.ts:111-118 — eligible when type === "message" && status === "active" && tagNumber <= protectedCutoff && byteSize >= minChars.
- Rust
crates/mc-module/src/transform.rs:6241-6260 — additionally requires !synthetic, is_tail(...), role ∈ {user, assistant}, and matches!(&block.wire.kind, CkKind::Text{..}) — exact Text only.
A large mixed assistant message (text plus tool blocks) is compressed by TS via its text target and skipped entirely by Rust. Different compression depth persists.
B4 — Channel-1 reminder spans are stripped from different surfaces
- TS
packages/plugin/src/hooks/magic-context/tail-hygiene-walk.ts:632 — stripChannel1ReminderSpans is applied to rawOutput only, i.e. tool output.
- Rust
crates/mc-module/src/tail_hygiene.rs:588 — applied to every user/assistant Text block before token measurement.
User text ending in a <system-reminder> span counts toward T/U under TS and is stripped under Rust. Since the nudge bands are thresholds on U/T (0.20/0.40/0.60, plus Channel 2 at 0.75), the same conversation can sit in different bands.
B5 — Context-limit fallback differs by 72k
- TS
packages/plugin/src/hooks/magic-context/event-resolvers.ts:17,86 — DEFAULT_CONTEXT_LIMIT = 128_000, used as the final fallback.
- Rust
crates/mc-module/src/transform.rs:5818 — effective_context_limit_tokens falls back to 200_000.0.
With no metadata, no detected limit and no geometry, 100k of usage reads as 78.1% under TS and 50% under Rust — different side of every threshold band.
Class C — persisted-state divergences (different rows written)
C1 — Content edits leave the mural cue stale
- TS
packages/plugin/src/features/magic-context/memory/storage-memory.ts:1036-1082 — clears mural_cue, mural_cue_hash, mural_cue_at (and resets mural_cue_rejection_count) when content changes.
- Rust
crates/mc-store/src/lib.rs:11168-11175 — update_memory_content sets only content, normalized_hash, updated_at, shareable = 0, classified_at = NULL.
mc_memories carries all four mural-cue columns (added at lib.rs:2276-2279), so this is a genuine omission rather than an absent concept: a cue rendered from deleted text survives the edit.
Scope correction worth recording: my first pass had this as six missing invalidations, including memory_embeddings and memory_verifications. That was wrong — mc-store contains zero references to either table, so it does not own that state and cannot be skipping its cleanup. Only the mural-cue columns are a real gap.
C2 — Note updates: Rust trims, versions, and rejects empty; TS does none of it
- TS
packages/plugin/src/features/magic-context/storage-notes.ts:367-400 — updateNote stores updates.content verbatim. Zero references to trim or status_version.
- Rust
crates/mc-store/src/lib.rs:13144-13175 — update_note_cas requires matching status/status_version, applies content.map(str::trim), returns Conflict on empty trimmed content, and increments status_version.
Updating a note to " spaces " stores the spaces under TS and the trimmed form under Rust; all-whitespace content is accepted by TS and rejected by Rust. Concurrent-write semantics differ too — a race that succeeds under TS conflicts under Rust.
C3 — Smart-note compiled metadata is dropped
- TS
packages/plugin/src/tools/ctx-note/tools.ts:327-347,404-416 — persists compiled_provider, compiled_config, compiled_at, compile_status alongside the condition.
- Rust
crates/mc-store/src/lib.rs:4199-4208 — NoteWriteInput carries surface_condition and no compiled fields at all.
A smart note written under Rust authority is stored uncompiled, so the dreamer's evaluation path sees a different note than the same write under TS would produce.
C4 — Merge picks a different canonical row
- TS
packages/plugin/src/tools/ctx-memory/tools.ts:911-985 — looks for a row matching the merged content hash, may insert a new canonical row via insertMemoryIdempotent, and supersedes every source row other than the canonical one.
- Rust
crates/mc-module/src/lib.rs:14164 — Some((ids[0], ids[1..].to_vec())); merge_memories rewrites the target row in place and supersedes only the remaining ids.
ctx_memory(action="merge", ids=[1,2], content="C") with active memories 1 and 2: TS can create id 3 and supersede both; Rust rewrites id 1 and supersedes only id 2. Resulting ids, merged_from lineage, and any later lookup by id all differ.
The meta-finding: goldens pass, but coverage is narrower than the contract
There is a real differential harness — crates/mc-module/src/differential_goldens.rs ("TS emits fixtures, Rust consumes them in-process") plus ~40 fixtures under crates/mc-module/testdata/. It passes: I built the module at da47b516 and got 1150 passed / 0 failed / 4 ignored.
Several divergences above nonetheless sit in cases no fixture exercises. The clearest is A1: decay-golden.json has no non-BMP characters, so the one truncation case where UTF-16 and scalar counting disagree is exactly the case not tested.
If only one thing comes out of this issue, the highest-leverage fix is probably adding the adversarial cases to the existing goldens — astral Unicode at the truncation boundary, a resultless tool arc in selection-golden.json, a mixed text+tool message in the caveman fixtures. That converts this class of drift from "found by reading" to "caught by CI", which is worth more than any individual fix below.
What I checked and rejected
Recording these because the rejections mark where the real boundaries are, and because two of them are traps a future reader will hit in the same way I did:
- Drop placeholder
[dropped] vs [dropped §N§] — not a divergence. mc-store stores the canonical [dropped] marker; crates/mc-module/src/transform.rs:12496-12509 maps it through the tag overlay to [dropped §N§] at render time. Store layer vs wire layer. Arguably the better design, since the stored form doesn't bake in a tag number.
memory_embeddings / memory_verifications not cleaned on content update — not a divergence. mc-store has zero references to either; it does not own that state.
<covered-system-messages> in Rust m[0] but not TS — real structural difference, but intentional: it has a passing golden (covered-system-transform-golden.json) and ARCHITECTURE.md documents the module "absorbing covered system-role messages into the m0 baseline".
ctx_memory(scope:"global") routing — specific to a downstream fork that removed the external-memory backend; not an upstream concern.
- Pressure-backstop constants,
min_chunk_tokens — checked, no divergence. All four backstop constants match exactly (500, 0.15, 0.20, >40).
Provenance
Found by comparing the two implementations slice by slice, then verifying every surviving claim by opening both sides. I was initially sceptical that a shipped port could diverge in a dozen places and predicted most would fall — that prediction was wrong, and the arithmetic is worth stating plainly: of 15 candidates, 12 held up, 1 was intentional, 1 was fork-specific, and 1 was a layer-confusion error on my part. "Upstream ships it" implies the goldens pass; it does not imply parity.
Happy to send PRs. The cheap mechanical ones are B5 (one constant), C1 (four columns), and A2 (a field copy). B2 wants a maintainer's judgement on whether the guard belongs in active_arcs or in expand_arc, and B1's direction question is genuinely a design call rather than an obvious bug.
Summary
transform_modeis a runtime toggle over the same database and the same session, so the Rust module and the TypeScript plugin are two implementations of one contract. A slice-by-slice comparison found 12 places where they produce different observable behaviour for the same input.Each item below was verified by opening both sides. Where I could construct the concrete divergent input, it is given. Findings I could not substantiate were dropped — see "What I checked and rejected" at the end, because the rejections are informative about where the boundaries actually are.
The meta-finding is at the bottom and may matter more than any single item: the differential goldens pass, and several of these divergences sit in cases no golden exercises. A passing
cargo testdoes not currently imply parity.Class A — wire-byte divergences (prompt cache busts on mode flip)
A1 — Legacy truncation counts UTF-16 units in TS, Unicode scalars in Rust
packages/plugin/src/hooks/magic-context/decay-render.ts:104—content.slice(0, 420)/content.slice(0, 1_200).String.prototype.sliceindexes UTF-16 code units.crates/mc-module/src/decay_render.rs:186—content.chars().take(max); counts Unicode scalar values.Input
"U:\n" + "a".repeat(416) + "😀b"— 421 scalars, 422 UTF-16 units:Different m[0] bytes for the same legacy compartment. The Rust docstring already acknowledges this ("Char-boundary safe (vs the TS UTF-16 slice…)"), so the divergence is known at the source — it just isn't reconciled or covered by a golden.
crates/mc-core/testdata/decay-golden.jsoncontains zero non-BMP characters and never exercises the 420/1200 boundary.A2 — Reasoning sentinels drop cache metadata and preserve the original type
packages/plugin/src/hooks/magic-context/sentinel.ts:105-121—makeSentinelforcestype: "text"and copiescache_control/cacheControlfrom the original part.crates/mc-module/src/transform.rs:13037-13052—empty_reasoning_sentinelemits onlytype(preserved from the original, defaulting to"reasoning") plusthinkingortext. No cache metadata copy.Clearing
{type:"reasoning", text:"…", cache_control:{type:"ephemeral"}}gives TS{type:"text", text:"", cache_control:{…}}and Rust{type:"reasoning", text:""}. Three differences: the type field, the key, and the dropped cache-control boundary. This one is doubly relevant because it changes where a provider cache breakpoint lands.A3 — Edit-marker canonicalises JSON key order; TS preserves insertion order
packages/plugin/src/hooks/magic-context/edit-marker.ts:53-62—applyEditMarkerToInputmutates the input object in place; JS preserves insertion order on serialise.crates/mc-module/src/selection.rs:609-624—canonical_jsonsorts object keys ("deterministic bytes across passes").{"z":…,"filePath":…,"content":…}serialises asz,filePath,contentunder TS andcontent,filePath,zunder Rust.Caveat, stated because it is the exact trap that killed one of my other candidates: I confirmed the canonicalisation at
selection.rs, but did not prove the canonicalised payload reaches the wire rather than remaining a frozen store artifact that is re-rendered later. If the render path re-serialises through the host's own encoder, this is a non-issue. Worth a maintainer's five seconds before anyone acts on it.Class B — decision divergences (same input, different fold/reclaim outcome)
B1 — Completed tool-arc fencing moves in opposite directions
packages/plugin/src/hooks/magic-context/protected-tail-boundary.ts:371-372—end = Math.min(protectedTailStart, resOrdinal + 1). Always forward: extend to include the whole arc.crates/mc-module/src/boundary.rs,fence_boundary_for_completed_tool_arcs—if min_invocation < floor { max_result + 1 } else { min_invocation }. Forward only when the invocation is below the publication floor; otherwise backward, excluding the entire arc.Both honour arc atomicity, but they resolve the same crossing in opposite directions. With
offset=1,protected_tail_start=5, a completed arc at inv=2/res=4 and a head-cap end of 3: TS yields1..5(arc retained), Rust yields1..2(arc excluded). The historian receives a different chunk, so different compartments are durably persisted depending on which mode was active.B2 — Reclaim can rewrite an in-flight tool call
packages/plugin/src/hooks/magic-context/tool-drop-target.ts:284-291—partHasCompletedResultrequiresstate.outputto be a string orstatus === "error". The three reclaim methods (:386,:399,:414) eachreturn "incomplete"when!entry.hasResult.crates/mc-module/src/selection.rs:707-728—expand_arciteratesarc.call_inputsand emits aReductionDecisionfor each call block before it touchesresult_ids.group_arcs(:329) initialisesresult_ids: Vec::new(), theactive_arcsfilter checks only!reduced && !provider_executed && !reasoning_ineligible, and the soleresult_ids.is_empty()guard in the file (:833) is scoped toselect_tool_dedup.So an arc with call inputs and no result yet still yields a decision that rewrites the call block. TS explicitly refuses. I'd rank this the most serious item here: it is a correctness problem in its own right, not merely a mode-flip inconsistency. I have not constructed a live repro proving such an arc reaches selection in practice — but TS guards against it in three separate places, which is reasonable evidence that it is reachable.
B3 — Caveman eligibility is narrower in Rust
packages/plugin/src/hooks/magic-context/caveman-cleanup.ts:111-118— eligible whentype === "message" && status === "active" && tagNumber <= protectedCutoff && byteSize >= minChars.crates/mc-module/src/transform.rs:6241-6260— additionally requires!synthetic,is_tail(...),role ∈ {user, assistant}, andmatches!(&block.wire.kind, CkKind::Text{..})— exactTextonly.A large mixed assistant message (text plus tool blocks) is compressed by TS via its text target and skipped entirely by Rust. Different compression depth persists.
B4 — Channel-1 reminder spans are stripped from different surfaces
packages/plugin/src/hooks/magic-context/tail-hygiene-walk.ts:632—stripChannel1ReminderSpansis applied torawOutputonly, i.e. tool output.crates/mc-module/src/tail_hygiene.rs:588— applied to every user/assistantTextblock before token measurement.User text ending in a
<system-reminder>span counts towardT/Uunder TS and is stripped under Rust. Since the nudge bands are thresholds onU/T(0.20/0.40/0.60, plus Channel 2 at 0.75), the same conversation can sit in different bands.B5 — Context-limit fallback differs by 72k
packages/plugin/src/hooks/magic-context/event-resolvers.ts:17,86—DEFAULT_CONTEXT_LIMIT = 128_000, used as the final fallback.crates/mc-module/src/transform.rs:5818—effective_context_limit_tokensfalls back to200_000.0.With no metadata, no detected limit and no geometry, 100k of usage reads as 78.1% under TS and 50% under Rust — different side of every threshold band.
Class C — persisted-state divergences (different rows written)
C1 — Content edits leave the mural cue stale
packages/plugin/src/features/magic-context/memory/storage-memory.ts:1036-1082— clearsmural_cue,mural_cue_hash,mural_cue_at(and resetsmural_cue_rejection_count) when content changes.crates/mc-store/src/lib.rs:11168-11175—update_memory_contentsets onlycontent,normalized_hash,updated_at,shareable = 0,classified_at = NULL.mc_memoriescarries all four mural-cue columns (added atlib.rs:2276-2279), so this is a genuine omission rather than an absent concept: a cue rendered from deleted text survives the edit.Scope correction worth recording: my first pass had this as six missing invalidations, including
memory_embeddingsandmemory_verifications. That was wrong —mc-storecontains zero references to either table, so it does not own that state and cannot be skipping its cleanup. Only the mural-cue columns are a real gap.C2 — Note updates: Rust trims, versions, and rejects empty; TS does none of it
packages/plugin/src/features/magic-context/storage-notes.ts:367-400—updateNotestoresupdates.contentverbatim. Zero references totrimorstatus_version.crates/mc-store/src/lib.rs:13144-13175—update_note_casrequires matchingstatus/status_version, appliescontent.map(str::trim), returnsConflicton empty trimmed content, and incrementsstatus_version.Updating a note to
" spaces "stores the spaces under TS and the trimmed form under Rust; all-whitespace content is accepted by TS and rejected by Rust. Concurrent-write semantics differ too — a race that succeeds under TS conflicts under Rust.C3 — Smart-note compiled metadata is dropped
packages/plugin/src/tools/ctx-note/tools.ts:327-347,404-416— persistscompiled_provider,compiled_config,compiled_at,compile_statusalongside the condition.crates/mc-store/src/lib.rs:4199-4208—NoteWriteInputcarriessurface_conditionand no compiled fields at all.A smart note written under Rust authority is stored uncompiled, so the dreamer's evaluation path sees a different note than the same write under TS would produce.
C4 — Merge picks a different canonical row
packages/plugin/src/tools/ctx-memory/tools.ts:911-985— looks for a row matching the merged content hash, may insert a new canonical row viainsertMemoryIdempotent, and supersedes every source row other than the canonical one.crates/mc-module/src/lib.rs:14164—Some((ids[0], ids[1..].to_vec()));merge_memoriesrewrites the target row in place and supersedes only the remaining ids.ctx_memory(action="merge", ids=[1,2], content="C")with active memories 1 and 2: TS can create id 3 and supersede both; Rust rewrites id 1 and supersedes only id 2. Resulting ids,merged_fromlineage, and any later lookup by id all differ.The meta-finding: goldens pass, but coverage is narrower than the contract
There is a real differential harness —
crates/mc-module/src/differential_goldens.rs("TS emits fixtures, Rust consumes them in-process") plus ~40 fixtures undercrates/mc-module/testdata/. It passes: I built the module atda47b516and got 1150 passed / 0 failed / 4 ignored.Several divergences above nonetheless sit in cases no fixture exercises. The clearest is A1:
decay-golden.jsonhas no non-BMP characters, so the one truncation case where UTF-16 and scalar counting disagree is exactly the case not tested.If only one thing comes out of this issue, the highest-leverage fix is probably adding the adversarial cases to the existing goldens — astral Unicode at the truncation boundary, a resultless tool arc in
selection-golden.json, a mixed text+tool message in the caveman fixtures. That converts this class of drift from "found by reading" to "caught by CI", which is worth more than any individual fix below.What I checked and rejected
Recording these because the rejections mark where the real boundaries are, and because two of them are traps a future reader will hit in the same way I did:
[dropped]vs[dropped §N§]— not a divergence.mc-storestores the canonical[dropped]marker;crates/mc-module/src/transform.rs:12496-12509maps it through the tag overlay to[dropped §N§]at render time. Store layer vs wire layer. Arguably the better design, since the stored form doesn't bake in a tag number.memory_embeddings/memory_verificationsnot cleaned on content update — not a divergence.mc-storehas zero references to either; it does not own that state.<covered-system-messages>in Rust m[0] but not TS — real structural difference, but intentional: it has a passing golden (covered-system-transform-golden.json) and ARCHITECTURE.md documents the module "absorbing covered system-role messages into them0baseline".ctx_memory(scope:"global")routing — specific to a downstream fork that removed the external-memory backend; not an upstream concern.min_chunk_tokens— checked, no divergence. All four backstop constants match exactly (500,0.15,0.20,>40).Provenance
Found by comparing the two implementations slice by slice, then verifying every surviving claim by opening both sides. I was initially sceptical that a shipped port could diverge in a dozen places and predicted most would fall — that prediction was wrong, and the arithmetic is worth stating plainly: of 15 candidates, 12 held up, 1 was intentional, 1 was fork-specific, and 1 was a layer-confusion error on my part. "Upstream ships it" implies the goldens pass; it does not imply parity.
Happy to send PRs. The cheap mechanical ones are B5 (one constant), C1 (four columns), and A2 (a field copy). B2 wants a maintainer's judgement on whether the guard belongs in
active_arcsor inexpand_arc, and B1's direction question is genuinely a design call rather than an obvious bug.