fix(core): key a refused argument so it never matches a valid call (#765) - #766
Conversation
…or takes The args segment of a query key is the JSON of the arguments, and JSON writes some values a reactor refuses exactly as it writes values the reactor takes. With the valid call cached, fetchQuery or a query hook answered the refused call from the cache instead of failing (#765): - `undefined` where Candid `null` is required, or for an empty record, which JSON writes as `null` in an array. Both reactors. - A bigint, written as its digits (#515): the key of that text for a `text` in both reactors, and for a DisplayReactor's `nat`, `int`, small integers and floats, whose codecs take text and refuse a bigint. - Text for a Reactor's integer: the key of the bigint it spells. - `{ __principal__ }`, which JSON.parse returns for a Principal. Both reactors. - In a Reactor, a variant with an `undefined` beside its arm, a record without Object.prototype, and a record without its `reserved` field. - In a DisplayReactor, text for a map, which shares the key of a Date or a boxed value that the map codec takes as its (empty) entries. The key walk from #757 and #764 now checks each primitive, record and variant as the codec or IDL.encode does, and writes a value they refuse as a RefusedKey: `"\u0000refused:"` and the key of the value, behind the tag no argument can produce. A value they take comes back as the same object, so its key is unchanged. Forms that send the same bytes now share a key: numeric text for a DisplayReactor's float or integer of 32 bits or fewer, a Principal for its text, fields a record does not declare, and any value of `reserved`, which is keyed as `null`. That also keeps apart `{ A: undefined }` and `{ B: undefined }` for a Reactor's variant of `reserved` arms, which sent different arms under one key. The codecs' number parsing moved into two functions the key calls too, so the two cannot drift. reactor-refused-args-query-key.test.ts caches the valid call, then fetches the refused one and expects it to fail: its 23 tests of refused values and merged forms fail on #764's head and pass here. Two guards check that keys of arguments in the forms the codecs return stay byte for byte what they were, and that distinct values keep distinct keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 271b753f8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) { | ||
| return new RefusedKey(value) | ||
| } | ||
| return this.mapFields(value, fields, true) |
There was a problem hiding this comment.
Tag records that shadow hasOwnProperty
When a raw Reactor receives a nonempty record with an undeclared own hasOwnProperty value—for example, { a: 1n, hasOwnProperty: false }—the SDK rejects it because RecordClass invokes x.hasOwnProperty(...). This call strips that undeclared field before serialization, producing the same query key as the valid { a: 1n }; if that valid call is cached, fetchQuery returns its data without reaching the encoder. Detect a shadowed hasOwnProperty before dropping undeclared fields and key the argument as refused.
Useful? React with 👍 / 👎.
Closes #765
Branch
fix/core-refused-arg-keys, one commit on top of #764's head (fix/core-display-arg-forms-key). It changes the same key walk,packages/core/src/utils/args-key.ts.What was wrong
The args segment of a query key is the JSON of the arguments. JSON writes some values a reactor refuses exactly as it writes values the reactor takes. So when the valid call was cached,
fetchQueryor a query hook answered the refused call from the cache instead of failing. Measured on #764's head:null, both reactorsundefinednull[null]record {}, bothundefinednull[null]text, both10n"10"["10"]nat,nat32,float64, DisplayReactor10n"10"["10"]nat, Reactor"10"10n["10"]principal, both{ __principal__: "aaaaa-aa" }, which is whatJSON.parsereturns for a Principal[{"__principal__":"aaaaa-aa"}]{ All: null, ByMemo: undefined }{ All: null }[{"All":null}]record { id: nat; old: reserved }, Reactor{ id: 1n }{ id: 1n, old: undefined }[{"id":"1"}]vec record { text; nat }, DisplayReactor["1970-01-01T00:00:00.000Z"]Boxed values collide the same way. For example,
new Boolean(true)is refused for aboolbut has the JSON oftrue.One case gave wrong data between two accepted calls. A Reactor's variant of
reservedarms takes{ A: undefined }and{ B: undefined }. They send different arms, but both keyed as[{}], so the second call got the first one's result.Item 2 of the issue: some forms that send the same bytes got separate keys, which meant extra calls.
What changed
All in the key walk that
Reactor.argsForQueryKeyandDisplayReactor.argsForQueryKeyrun since #757 and #764.Refused values (item 1). The walk checks each value the way the codec or IDL.encode checks it. A value they refuse is written as a
RefusedKey, whichgenerateKeywrites as"\u0000refused:"followed by the value's own key. That is the U+0000 tag that also leads a blob's key (#757). No argument can produce it, because a string that starts with U+0000 gets another one. Checked:null: anything butnull.bool: not a boolean.text: not a string.nat,intand 64-bit integers: not text. Its integers of 32 bits or fewer and its floats: not a number or text.principal: in a Reactor, not marked_isPrincipal, which is all IDL.encode asks. In a DisplayReactor, neither text nor a Principal.nullwhen the record has fields. In a Reactor, also an object without Object.prototype, since IDL.encode calls the value's ownhasOwnProperty.A value of the right JavaScript type that is still refused keeps its key, because no accepted value has the same JSON. Examples are
-1for anatand"abc"for a DisplayReactor'snat. A Reactor's floats are not checked. IDL.encode takes any number there, boxed or not, and the values it refuses have no number's JSON. The one collision left there is a boxedNaN: IDL.encode takes it, and JSON writes it asnull, which is also the key of a refusednull.Forms that send the same bytes (item 2).
10,"10"and"010"share a key, and an integer's-0and"-0"share the key of0. The text is read with the codec's own parsing, which moved from inside the two codecs tofixedNumberOf/floatNumberOfindisplay/visitor.ts, with the same checks and messages. The key and the codec cannot drift apart.reservedis keyed asnull, the value it decodes to, in both reactors. A DisplayReactor also keys an absentreservedfield asnull, since its record codec takes it. And it leaves areservedarm's payload out, as for anullarm. This fixes the variant-of-reservedcase above.Not merged, and why:
nattext ("010"," 10 ","0x0a"): merging them means a BigInt parse of everynaton every key.null, an array or a boxed value.A value already in the form the codecs return comes back as the same object, so its key is byte-identical to #764's.
Docs: one sentence group each in
docs/.../framework/query-caching.mdx,docs/.../reference/Reactor.mdxandpackages/core/README.md, after #764's. The doc comments of bothargsForQueryKeymethods say the same.Evidence
pnpm verify:test-fails tests/reactor-refused-args-query-key.test.ts --package core --base origin/fix/core-display-arg-forms-key:--base origin/maingives the same 23 and 2. Main now contains #764 (7a843dee2), with the same tree as its head.fetchQueryof the refused one to reject. It also asserts the premise:callMethodwith the refused value rejects before reaching the agent.reservedkeyed asundefined; a Reactor's Principal keyed as its text. Each break fails at least one guard.Fuzz. Not committed; it extends #764's harness. It runs 49 argument shapes: #764's 26, plus primitives, principals, an ICRC account,
reservedfields and arms, empty records and a five-argument list. Each shape runs inReactorandDisplayReactormode, in the codecs' forms and in all merged forms, with and without refused values: 392 cases of 4,000 values each. The refused values include a "twin" of each generated value built to have the same JSON: a bigint for numeric text, text for a bigint,undefinedfornull, boxed values,{ __principal__ }, a variant with anundefinedarm, and a record without Object.prototype.reservedarms). 25 more break the second check.Keys of accepted values. The same generated values were keyed under #764's head and this branch: 294,000 accepted values in all.
reservedwritten asnull. None changed in any other way.Cost.
generateQueryKeywith a 100,000-element argument, mean of 10 calls, 3 runs each, #764's head and this branch measured back to back:{ principal; nat }vec nat/vec text{ principal; nat }{ nat32; float64 }as numbersvec textIn the forms the codecs return, keys cost 6–30% more, and the map cases 36–50%. The text-number row is 68% more: it now parses the text and copies each record, which is the merge.
Gates
pnpm --filter @ic-reactor/core build, then tests: core 814 passed / 2 skipped; react 720 passed / 15 skipped; candid 649 passed.pnpm --filter @ic-reactor/core --filter @ic-reactor/react --filter @ic-reactor/candid typecheck: clean.npx eslint packages/core packages/react packages/candid: clean.prettier --checkon the nine changed files: clean.pnpm --filter @ic-reactor/core size: 36.39 kB of 50 kB (35.82 kB on fix(core): key DisplayReactor args by the value they send, not the form given (#761, #762) #764).Not changed (noted for follow-up)
[{}]and sent different bytes, measured on this branch and on main.Mapgiven for a DisplayReactor'svec record { text; T }is sent as an empty vector, because the codec readsObject.entries, which is[]for a Map. The key now matches what is sent, but the entries are dropped without an error.fetchQueryreturns a cached entry without running the validator, as before.🤖 Generated with Claude Code