Skip to content

fix(core): key a refused argument so it never matches a valid call (#765) - #766

Merged
b3hr4d merged 1 commit into
mainfrom
fix/core-refused-arg-keys
Sep 23, 2026
Merged

b3hr4d merged 1 commit into
mainfrom
fix/core-refused-arg-keys

Conversation

@b3hr4d

@b3hr4d b3hr4d commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

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, fetchQuery or a query hook answered the refused call from the cache instead of failing. Measured on #764's head:

position refused taken shared args segment
null, both reactors undefined null [null]
record {}, both undefined null [null]
text, both 10n "10" ["10"]
nat, nat32, float64, DisplayReactor 10n "10" ["10"]
nat, Reactor "10" 10n ["10"]
principal, both { __principal__: "aaaaa-aa" }, which is what JSON.parse returns for a Principal the Principal [{"__principal__":"aaaaa-aa"}]
variant, Reactor { All: null, ByMemo: undefined } { All: null } [{"All":null}]
record, Reactor the record without Object.prototype the plain record the same
record { id: nat; old: reserved }, Reactor { id: 1n } { id: 1n, old: undefined } [{"id":"1"}]
vec record { text; nat }, DisplayReactor a Date's text the Date, which the map codec takes as no entries ["1970-01-01T00:00:00.000Z"]

Boxed values collide the same way. For example, new Boolean(true) is refused for a bool but has the JSON of true.

One case gave wrong data between two accepted calls. A Reactor's variant of reserved arms 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.argsForQueryKey and DisplayReactor.argsForQueryKey run 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, which generateKey writes 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 but null. bool: not a boolean. text: not a string.
  • A Reactor's integers: not a bigint or a number. A DisplayReactor's nat, int and 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.
  • A record: not an object, or null when the record has fields. In a Reactor, also an object without Object.prototype, since IDL.encode calls the value's own hasOwnProperty.
  • A Reactor's variant given as a plain object: refused unless it has exactly one own key, and that key names an arm. Also refused when it has no Object.prototype.
  • A DisplayReactor's map: fix(core): key DisplayReactor args by the value they send, not the form given (#761, #762) #764 read only a plain object as entries. The codec reads any object that is not an array, so the walk now does too.

A value of the right JavaScript type that is still refused keeps its key, because no accepted value has the same JSON. Examples are -1 for a nat and "abc" for a DisplayReactor's nat. 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 boxed NaN: IDL.encode takes it, and JSON writes it as null, which is also the key of a refused null.

Forms that send the same bytes (item 2).

  • A DisplayReactor's float or integer of 32 bits or fewer given as text is keyed as the number its codec sends. 10, "10" and "010" share a key, and an integer's -0 and "-0" share the key of 0. The text is read with the codec's own parsing, which moved from inside the two codecs to fixedNumberOf / floatNumberOf in display/visitor.ts, with the same checks and messages. The key and the codec cannot drift apart.
  • A DisplayReactor's Principal is keyed as its text.
  • Fields a record does not declare are left out, in both reactors. Neither IDL.encode nor the record codec sends them.
  • Every value of reserved is keyed as null, the value it decodes to, in both reactors. A DisplayReactor also keys an absent reserved field as null, since its record codec takes it. And it leaves a reserved arm's payload out, as for a null arm. This fixes the variant-of-reserved case above.

Not merged, and why:

  • A typed array vs a plain array for a non-blob vector: fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757 pins both keys, and a canonical form would have to be chosen.
  • A Reactor's number vs bigint: not in the issue.
  • Other spellings of a DisplayReactor's nat text ("010", " 10 ", "0x0a"): merging them means a BigInt parse of every nat on every key.
  • A Reactor tuple's extra elements, and an empty record given 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.mdx and packages/core/README.md, after #764's. The doc comments of both argsForQueryKey methods 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:

 23 caught the bug   (fail on origin/fix/core-display-arg-forms-key, pass now)
      ✓ a refused argument (#765) undefined for null in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) undefined for null in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) undefined for an empty record in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) undefined for an empty record in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a bigint for text in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a bigint for text in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) text for a nat in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a bigint for a nat in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a bigint for a nat32 in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a bigint for a float64 in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a parsed Principal for a principal in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a parsed Principal for a principal in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a variant with an undefined beside its arm in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a record without Object.prototype in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) a record without its reserved field in a Reactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) the text of a Date for a map in a DisplayReactor is not answered from the valid call's cache entry
      ✓ a refused argument (#765) keeps apart two arms of a Reactor's variant whose payload is reserved
      ✓ a refused argument (#765) writes a refused value behind a tag no argument can spell
      ✓ forms of one value, which send the same bytes (#765) gets one key for a DisplayReactor's small integer as a number or as text
      ✓ forms of one value, which send the same bytes (#765) gets one key for a DisplayReactor's float as a number or as text
      ✓ forms of one value, which send the same bytes (#765) gets one key for a principal as a Principal or as its text
      ✓ forms of one value, which send the same bytes (#765) leaves out the fields a record does not declare
      ✓ forms of one value, which send the same bytes (#765) gets one key for every value of reserved

  2 passed either way  (invariant guards, or vacuous — check each)
      • forms of one value, which send the same bytes (#765) keeps distinct values distinct
      • the key of an argument in the forms the codecs return is byte-identical to the key before

--base origin/main gives the same 23 and 2. Main now contains #764 (7a843dee2), with the same tree as its head.

  • Each refused-value test first caches the valid call, then expects fetchQuery of the refused one to reject. It also asserts the premise: callMethod with the refused value rejects before reaching the agent.
  • Each merged-form test checks the bytes the agent receives: the forms given one key send identical bytes.
  • The two guards are not vacuous. I broke the fix five ways, one at a time: numeric text keyed as 0; declared fields dropped along with undeclared ones; every text refused; reserved keyed as undefined; 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, reserved fields and arms, empty records and a five-argument list. Each shape runs in Reactor and DisplayReactor mode, 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, undefined for null, boxed values, { __principal__ }, a variant with an undefined arm, and a record without Object.prototype.

Keys of accepted values. The same generated values were keyed under #764's head and this branch: 294,000 accepted values in all.

  • All 147,000 in the forms the codecs return have byte-identical keys.
  • Of the 147,000 in other forms, 18,986 keys changed. 17,915 now equal the key of the codec form that sends the same bytes. The other 1,071 are the same key with an undeclared field removed, a Principal written as its text, or reserved written as null. None changed in any other way.

Cost. generateQueryKey with a 100,000-element argument, mean of 10 calls, 3 runs each, #764's head and this branch measured back to back:

argument #764 head this PR
Reactor: records { principal; nat } 99–103 ms 109–111 ms
Reactor: records with an opt and a variant 140–143 ms 165–173 ms
Reactor: vec nat / vec text 8–9 / 9 ms 10 / 7–10 ms
DisplayReactor: records { principal; nat } 32–33 ms 41–43 ms
DisplayReactor: records with an opt and a variant, codec forms 81–82 ms 95–99 ms
DisplayReactor: the same records in other forms 108–109 ms 108–111 ms
DisplayReactor: records { nat32; float64 } as numbers 34 ms 44 ms
DisplayReactor: the same as text 32–34 ms 56–57 ms
DisplayReactor: a map of 100,000 entries, as an object / as pairs 33–34 / 16 ms 46 / 24–25 ms
DisplayReactor: vec text 5.6 ms 6.8 ms

In 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 --check on 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)

  • A record or variant given as a class instance is not walked, as in fix(core): key a blob argument by its bytes, whatever form it is passed in (#748) #757 and fix(core): key DisplayReactor args by the value they send, not the form given (#761, #762) #764, so a refused value inside one keeps its old key. A DisplayReactor's record codec also reads a class's getters, which JSON leaves out. Two instances whose getters return different amounts both keyed as [{}] and sent different bytes, measured on this branch and on main.
  • A JS Map given for a DisplayReactor's vec record { text; T } is sent as an empty vector, because the codec reads Object.entries, which is [] for a Map. The key now matches what is sent, but the entries are dropped without an error.
  • A DisplayReactor's validator sees the arguments as given. The key cannot account for a validator that reads a field the codec ignores, and fetchQuery returns a cached entry without running the validator, as before.

🤖 Generated with Claude Code

…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@b3hr4d
b3hr4d merged commit e41db6f into main Sep 23, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: a refused argument form (undefined for null, a bigint for a display nat) shares a query key with the valid call, so it can be served cached data

1 participant