#14392 delivered pushInsertStrategy (merged as #14393), giving Neo.data.Store#onPipelinePush() an opt-in path to insert unknown keyed pushes. That work is sound. Its tests covered numeric keys only, and the key's type was never part of the contract.
Surfaced downstream: a private downstream app's Store subclass accumulated two records under one identity from a websocket push. Diagnosis pointed at the engine rather than the app, and cross-family review placed the ownership here from exact-head falsifiers against Neo.data.Store itself — see the formal review on PR #17717 for the bearer record and the measurements.
The Problem
onPipelinePush() looks up the pushed key as it arrived on the wire, while insertion coerces it. A key whose wire type differs from its stored type is therefore not a near-miss but a miss, and an upsert answers a miss by appending.
The result is two records under one identity, steady, with nothing in the path that later removes either. Exact-head falsifiers:
store
push
observed
Integer-key holding 1
"1"
count: 2, ids [1, 1]
String-key holding "1"
1
count: 2, ids ["1", "1"] — both numeric and string map lookups resolve
Integer-key
"bad"
record field becomes NaN while the map stays keyed by "bad"; get("bad") resolves, get(NaN) does not
The third row matters as much as the first two: it shows an invalid key needs an explicit disposition, not just a guard on the lookup.
Downstream, a duplicated projection reads as converged rather than broken — a stability check comparing consecutive snapshots sees a steady wrong state and reports health, so the defect surfaces as an unrelated assertion several frames later.
The Architectural Reality
Collection.get() (src/collection/Base.mjs) is a strict Map lookup. map.get("1") and map.get(1) are different keys.
Store.add() routes through RecordFactory.createRecord() → parseRecordValue(), which coerces each field to its declared type — Integer, String, Float, Date, and custom convert.
Store.getKey(item) already resolves a key from a raw object or a Record, supports a dotted keyProperty, and falls back to a field mapping. It is the right extraction authority and is already correct.
Store.getKeyType() reports the declared type but performs no conversion.
Four engine sites already hand-roll the same coercion after calling it — src/list/Base.mjs:724, src/component/Gallery.mjs:516, src/component/Helix.mjs:816 each pair it with parseInt, and src/list/Buffered.mjs:532 with Number() plus its own null guard. Four call sites sharing a hand-rolled idiom is a missing primitive, and the push path is a fifth consumer that needs a stronger version than any of them.
The gap is that nothing answers "what key will insertion actually store for this value?" — only "what type is declared".
The Fix
Add a canonical key-normalization boundary on the Store/Model authority that returns the same key record insertion will store, and repair onPipelinePush() to use it.
It must span at least Integer and String and define an explicit disposition for a value that cannot be canonicalized. Centralizing only getKeyType()?.includes('int') → parseInt would be a DOM-id convenience wearing a general identity name; RecordFactory owns broader conversion semantics and the primitive must not silently disagree with it.
Deliberately not coercing inside Collection.get(): that is a hot path, and forgiving type mismatches there would mask genuine identity errors everywhere rather than surface them at the boundary that owns them.
Within a supported domain — a key field with no convert and no calculate, converting to a primitive — return the key record insertion will store, by delegating to that parser.
Outside the domain, refuse with undefined: convert (insertion passes the Record, a lookup cannot), calculate (derived from a record that does not exist yet), object/Date results (equal-but-distinct, and a Map compares by identity), NaN, and null/absent. A refused push is dropped, never inserted.
JSDoc states the supported domain and every refusal; the DataPipelines guide carries the same table.
Executable arms per supported type and per refused state, comparing against real insertion.
Store#onPipelinePush()
src/data/Store.mjs, #14392 contract
Resolve the pushed key through the primitive before lookup, and store the canonical key so a later lookup cannot miss it.
Unknown keys keep #14392's existing pushInsertStrategy semantics; only the key's type handling changes.
Consume the primitive instead of repeating a local type-report-then-convert idiom.
DOM-id call sites keep their useInternalId guard; Buffered keeps its logical-id/slot-id null contract, which a refusal now feeds.
—
Owning specs green, plus a Buffered arm binding the canonicalized id and the null contract.
Acceptance Criteria
A push whose key arrives as a string updates the existing record of an Integer-keyed Store instead of appending a second one.
The symmetric case holds: a numeric push against a String-keyed Store does not append a twin.
After an insert, the stored key is the canonical one — a subsequent get() with the canonical key resolves the pushed record.
A key that cannot be canonicalized takes one explicit documented disposition, and never leaves the record field and the map key disagreeing.
Inside the supported domain the primitive's result matches what RecordFactory would store for that field; a divergence between them is itself a test failure. Outside it — convert, calculate, object/Date, NaN, null/absent — the primitive refuses, and an executable arm proves each refusal rather than asserting the general case.
All four sites — list/Base.mjs, Gallery.mjs, Helix.mjs, list/Buffered.mjs — consume the primitive, with their owning specs green and Buffered's null contract bound by a test.
Coercion is not introduced into Collection.get().
Red proof per falsifier row: each fails against dev with the test present, and passes after — not "the suite went green once".
learn/guides/datahandling/DataPipelines.md states the key-type contract for pushes.
Out of Scope
Changing what the wire sends, or any transport/serialization contract.
Revisiting #14392's pushInsertStrategy vocabulary or its filter/remote-projection semantics.
Making Collection.get() type-tolerant.
The private downstream app's local workaround helper — it is deleted in that repository once this lands, and is not this ticket's surface.
Avoided Traps
Centralizing only the int branch. Three components hand-roll exactly that; lifting it unchanged under a general name would ship a DOM-id convenience as an identity contract, and the String falsifier above would still fail.
Guarding the lookup and not the stored value. The "bad" row is precisely this: a NaN guard that protects get() while insertion writes a field the map key no longer matches.
Hiding coercion in Collection.get(). It would pass every test here and silently forgive real identity mismatches everywhere else.
Treating this as an app-level rule. The Integer-key falsifier reproduces against Neo.data.Store with no application code involved.
Creation Checks
Live latest-open sweep: latest 20 open issues read at 2026-08-24T16:45:00Z; no equivalent found.
In-flight claim sweep: checked immediately before creation across all read states; no competing claim or in-flight branch on this lane. Ownership was confirmed with the other maintainer working this area before filing.
Prior-art: #14392 is CLOSED/COMPLETED and #14393 MERGED, so this is a defect in delivered work rather than a re-open of that scope.
Structure-map gate: N/A — the surface is src/data/ and src/collection/, not ai/, Agent OS, MCP, Memory Core, orchestration, or skills.
Related
#14392 · #14393 — the delivered pushInsertStrategy work whose tests covered numeric keys only
Retrieval Hint: query_raw_memories("Store pipeline push key type coercion duplicate record canonical key")
Retrieval Hint: onPipelinePush wire-typed key twin record getKeyType parseInt canonical normalization
⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code
tobiu referenced in commit 95bb9ff - "fix(data): a pipeline push keyed by its wire type no longer forks a record (#17716) (#17717) on Aug 24, 2026, 8:05 PM
Context
#14392deliveredpushInsertStrategy(merged as#14393), givingNeo.data.Store#onPipelinePush()an opt-in path to insert unknown keyed pushes. That work is sound. Its tests covered numeric keys only, and the key's type was never part of the contract.Surfaced downstream: a private downstream app's Store subclass accumulated two records under one identity from a websocket push. Diagnosis pointed at the engine rather than the app, and cross-family review placed the ownership here from exact-head falsifiers against
Neo.data.Storeitself — see the formal review on PR #17717 for the bearer record and the measurements.The Problem
onPipelinePush()looks up the pushed key as it arrived on the wire, while insertion coerces it. A key whose wire type differs from its stored type is therefore not a near-miss but a miss, and an upsert answers a miss by appending.The result is two records under one identity, steady, with nothing in the path that later removes either. Exact-head falsifiers:
1"1"count: 2, ids[1, 1]"1"1count: 2, ids["1", "1"]— both numeric and string map lookups resolve"bad"NaNwhile the map stays keyed by"bad";get("bad")resolves,get(NaN)does notThe third row matters as much as the first two: it shows an invalid key needs an explicit disposition, not just a guard on the lookup.
Downstream, a duplicated projection reads as converged rather than broken — a stability check comparing consecutive snapshots sees a steady wrong state and reports health, so the defect surfaces as an unrelated assertion several frames later.
The Architectural Reality
Collection.get()(src/collection/Base.mjs) is a strictMaplookup.map.get("1")andmap.get(1)are different keys.Store.add()routes throughRecordFactory.createRecord()→parseRecordValue(), which coerces each field to its declared type —Integer,String,Float,Date, and customconvert.Store.getKey(item)already resolves a key from a raw object or a Record, supports a dottedkeyProperty, and falls back to a fieldmapping. It is the right extraction authority and is already correct.Store.getKeyType()reports the declared type but performs no conversion.src/list/Base.mjs:724,src/component/Gallery.mjs:516,src/component/Helix.mjs:816each pair it withparseInt, andsrc/list/Buffered.mjs:532withNumber()plus its own null guard. Four call sites sharing a hand-rolled idiom is a missing primitive, and the push path is a fifth consumer that needs a stronger version than any of them.The gap is that nothing answers "what key will insertion actually store for this value?" — only "what type is declared".
The Fix
Add a canonical key-normalization boundary on the Store/Model authority that returns the same key record insertion will store, and repair
onPipelinePush()to use it.It must span at least
IntegerandStringand define an explicit disposition for a value that cannot be canonicalized. Centralizing onlygetKeyType()?.includes('int') → parseIntwould be a DOM-id convenience wearing a general identity name;RecordFactoryowns broader conversion semantics and the primitive must not silently disagree with it.Deliberately not coercing inside
Collection.get(): that is a hot path, and forgiving type mismatches there would mask genuine identity errors everywhere rather than surface them at the boundary that owns them.Contract Ledger
Store/ModelRecordFactory.parseRecordValue()conversion semanticsconvertand nocalculate, converting to a primitive — return the key record insertion will store, by delegating to that parser.undefined:convert(insertion passes the Record, a lookup cannot),calculate(derived from a record that does not exist yet), object/Dateresults (equal-but-distinct, and a Map compares by identity),NaN, andnull/absent. A refused push is dropped, never inserted.Store#onPipelinePush()src/data/Store.mjs,#14392contract#14392's existingpushInsertStrategysemantics; only the key's type handling changes.learn/guides/datahandling/DataPipelines.mdpush section.list/Base.mjs,Gallery.mjs,Helix.mjs,list/Buffered.mjsuseInternalIdguard;Bufferedkeeps its logical-id/slot-id null contract, which a refusal now feeds.Bufferedarm binding the canonicalized id and the null contract.Acceptance Criteria
Integer-keyed Store instead of appending a second one.String-keyed Store does not append a twin.get()with the canonical key resolves the pushed record.RecordFactorywould store for that field; a divergence between them is itself a test failure. Outside it —convert,calculate, object/Date,NaN,null/absent — the primitive refuses, and an executable arm proves each refusal rather than asserting the general case.list/Base.mjs,Gallery.mjs,Helix.mjs,list/Buffered.mjs— consume the primitive, with their owning specs green andBuffered's null contract bound by a test.Collection.get().devwith the test present, and passes after — not "the suite went green once".learn/guides/datahandling/DataPipelines.mdstates the key-type contract for pushes.Out of Scope
#14392'spushInsertStrategyvocabulary or its filter/remote-projection semantics.Collection.get()type-tolerant.Avoided Traps
"bad"row is precisely this: aNaNguard that protectsget()while insertion writes a field the map key no longer matches.Collection.get(). It would pass every test here and silently forgive real identity mismatches everywhere else.Neo.data.Storewith no application code involved.Creation Checks
2026-08-24T16:45:00Z; no equivalent found.#14392is CLOSED/COMPLETED and#14393MERGED, so this is a defect in delivered work rather than a re-open of that scope.src/data/andsrc/collection/, notai/, Agent OS, MCP, Memory Core, orchestration, or skills.Related
#14392·#14393— the deliveredpushInsertStrategywork whose tests covered numeric keys onlysrc/data/Store.mjs·src/data/RecordFactory.mjs·src/collection/Base.mjssrc/list/Base.mjs·src/component/Gallery.mjs·src/component/Helix.mjstest/playwright/unit/data/StorePush.spec.mjs·test/playwright/unit/data/PipelinePush.spec.mjslearn/guides/datahandling/DataPipelines.mdRetrieval Hint:
query_raw_memories("Store pipeline push key type coercion duplicate record canonical key")Retrieval Hint:onPipelinePush wire-typed key twin record getKeyType parseInt canonical normalization⚖️ Ada ·
@neo-opus-ada· Claude Opus 5 · Claude Code