Skip to content

fix(web_core): reject writes through a primitive value consistently - #2499

Merged
gspencergoog merged 4 commits into
a2ui-project:mainfrom
diegolopezrm:fix-datamodel-primitive-writes
Sep 4, 2026
Merged

fix(web_core): reject writes through a primitive value consistently#2499
gspencergoog merged 4 commits into
a2ui-project:mainfrom
diegolopezrm:fix-datamodel-primitive-writes

Conversation

@diegolopezrm

@diegolopezrm diegolopezrm commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Description

Addresses the web_core half of #2498. Rewritten after rebasing: this PR originally fixed the same bug in Dart and TypeScript, and #2439 has since landed the Dart fix, so what is left is web_core plus the Dart tests for the fix that arrived without any.

What this PR changes

One branch in DataModel.set, which is why the two rows below are a single diff hunk:

-    if (!this.data) {
+    if (this.data === undefined || this.data === null) {
       this.data = {};
+    } else if (typeof this.data !== 'object') {
+      throw new A2uiDataError(
+        `Cannot set path '${path}': the data model root is a primitive value.`,
+        path,
+      );
     }

The old guard was a truthiness test, so a primitive root behaved differently depending on whether it happened to be truthy:

operations before after
set('/', 1), set('/a', 2) TypeError: Cannot create property 'a' on number '1' A2uiDataError, root preserved
set('/', false), set('/a', 2) root discarded, result {a: 2} A2uiDataError, root preserved

A truthy primitive root fell through to current[lastSegment] = value on a number or a string; modules run in strict mode, so that throws a raw TypeError — outside the A2uiError hierarchy consumers catch, and not what the method's own doc comment promises:

 * @throws {A2uiDataError} If path is null, undefined, invalid for
 *     arrays/primitives, or contains forbidden segments

A falsy primitive root took the other path: !this.data was true, so a root holding false, 0 or '' — a value the caller put there — was silently replaced with a fresh object.

Verified against the built code, all six primitive roots now behave the same way and keep their value:

set('/', 1)      then set('/a', 2) -> A2uiDataError, root still 1
set('/', "text") then set('/a', 2) -> A2uiDataError, root still "text"
set('/', true)   then set('/a', 2) -> A2uiDataError, root still true
set('/', false)  then set('/a', 2) -> A2uiDataError, root still false
set('/', 0)      then set('/a', 2) -> A2uiDataError, root still 0
set('/', "")     then set('/a', 2) -> A2uiDataError, root still ""

Two Dart tests, covering the fix #2439 landed rather than one of mine. They assert the error type rather than its message, so they pass against that wording unmodified, and the case they pin — a write when the root itself is a primitive — had no test on main. Confirmed with @gspencergoog in review that keeping them here is fine.

What this PR no longer changes

The Dart fix in DataModel.set. #2439 landed the same correction at the same branch, raising the same A2uiDataError; I kept its wording and dropped mine.

What is not addressed here

Three findings from #2498 are decisions rather than defects, and none is in this PR:

  • set(path, null) removes the key in Dart and stores the null in web_core — 137 of the 221 diverging programs. The v0.9 schema distinguishes the two intents by whether value is present, but Dart's DataModel has no way to express that, and test/data_model_test.dart pins the removal deliberately. Fixing it means splitting the setter from a remover and having MessageProcessor branch on presence.
  • __proto__ is rejected by web_core and stored as an ordinary key by Dart.
  • Unbounded list indices in web_core, already filed as web_core: DataModel accepts unbounded list indices, amplifying the serialized client data model #2420.

Tests

Two per language, all four failing on main before the respective fix:

  • web_core — a primitive root rejected with A2uiDataError rather than TypeError, and each falsy root (false, 0, '') surviving a rejected write.
  • Dart — a write through a primitive, and a write when the root is a primitive, each expecting A2uiDataError with the untouched value still readable.

node --test "dist/**/*.test.js" in renderers/web_core: 421 passing. eslint src/v0_9/state/ reports 33 warnings, exactly as on main. dart test in dart/a2ui_core: 324 passing; dart analyze and dart format clean.

Compatibility

Both changes turn silence into an error, so a client that was quietly losing writes will now hear about them. No existing test in either package expected the old behaviour.

Pre-launch Checklist

One time:

For this PR:

  • I have updated the relevant CHANGELOG.md file. (glad to add entries; say where you would like them)
  • I updated/added relevant documentation. (the root-guard comment explains what it protects)
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes. (both package suites pass in full)

`DataModel.set` walks to the container holding the last segment and then
branches on whether it is a map or a list. When it was neither — the
path ran through a primitive, or the root itself was one — both branches
were skipped and the method returned normally, having written nothing.

The write was reported as applied while the value never landed, so the
client held state the agent believed it had set, with no error anywhere
to explain the difference. Intermediate segments already threw for this;
only the final container did not.

It now throws `A2uiDataError`, which is what `set` does everywhere else
it cannot honour a path, and matches how web_core answers the same
write.
`DataModel.set` guarded the root with `if (!this.data) this.data = {}`,
which is a truthiness test, so the root was replaced whenever it held a
falsy value. Setting the root to `false`, `0` or `''` and then writing
any path silently discarded that root and started a fresh object.

A truthy primitive root took the other path and reached
`current[lastSegment] = value` on a number or string, which in a module
(strict mode) throws a raw `TypeError` — outside the `A2uiError`
hierarchy consumers catch, and not what the method's own documentation
promises.

The root is now replaced only when it is absent, and a primitive root
raises `A2uiDataError`, the same answer this method already gives for a
primitive at any other depth.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the DataModel implementation in both Dart and TypeScript to prevent writing paths through primitive values. Specifically, attempting to write a path when the containing value or the root itself is a primitive now throws an A2uiDataError instead of silently dropping the write or replacing the root. Unit tests have been added to verify this behavior. There are no review comments, so I have no feedback to provide.

@diegolopezrm

Copy link
Copy Markdown
Contributor Author

Re-reviewed this change against a wider set of neighbouring cases, since it turns silence into errors in two implementations at once. Twelve cases, run through both, agreeing on all twelve:

Still accepted, as before:

operations result
set('/a', 's'), set('/a', 2) — overwrite a primitive {a: 2}
set('/', []), set('/0', 1) — array root, numeric segment [1]
set('/', 1), set('/', {k: 1}) — replace the whole root {k: 1}
set('/', null), set('/a', 1) — absent root still auto-vivifies {a: 1}

Now rejected, with the prior state left intact in every case: a write through a nested primitive, a primitive root, a falsy root (false, 0, ''), a non-numeric segment on an array root, and a write through a primitive list element.

The one I most wanted to rule out was a partially applied write — the walk creates intermediate containers as it goes, so an error at the last segment could in principle leave those behind. It cannot: containers are only created where nothing exists, and once a fresh container is created everything below it is empty, so no primitive can be met further down the same path. Confirmed by case 8 above — {keep: {deep: 1}, p: 's'} before and after the rejected write, no residue.

One adjacent case this PR deliberately does not address, found while checking the above. In Dart, a consumer that constructs the model with a strongly typed map gets a raw error rather than an A2uiDataError:

DataModel(<String, String>{'a': 'x'}).set('/a', 2);
// _TypeError: type 'int' is not a subtype of type 'String' of 'value'

It is the same shape as the TypeError fixed here on the web side, but it arrives through the public API rather than through a protocol message — jsonDecode yields Map<String, dynamic>, so the message path is unaffected — and closing it means deciding whether DataModel should normalise or copy initialData, which is a design question rather than part of this fix. Happy to open it separately if you would like it addressed.

…tive-writes

# Conflicts:
#	dart/a2ui_core/lib/src/core/data_model.dart
@diegolopezrm

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #2439 has landed, and the Dart half of this PR is no longer mine to make: #2439 fixes the same silent write in DataModel.set with the same A2uiDataError, at the same branch. I kept its wording and dropped my version of that hunk.

What is left here:

  • web_core, unchanged and still needed. The falsy-root bug (if (!this.data) discarding a root holding false, 0 or '') and the raw TypeError on a truthy primitive root are both still present on main.
  • The Dart tests, which now cover [dart] Extend a2ui_core for agent SDKs, limited to protocol v0.9 #2439's fix rather than my own. They assert the error type rather than its message, so they pass against that wording unmodified — and the case they pin, a write when the root itself is a primitive, has no test in main.

So this went from "fix two implementations" to "fix web_core, and add the regression tests for the Dart side that just landed". Happy to split the Dart tests into their own PR if that reads better.

@github-actions github-actions Bot added status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs and removed status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs labels Sep 4, 2026
@Varun-S10

Copy link
Copy Markdown
Collaborator

Hi @gspencergoog, I have pulled and verified the changes locally, and all tests pass as expected. Could you please review this PR?

@gspencergoog gspencergoog changed the title fix(dart, web_core): reject writes through a primitive value consistently fix(web_core): reject writes through a primitive value consistently Sep 4, 2026
expect(model.get('/'), isEmpty);
});

test('rejects writes through a primitive value', () {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a Dart test, but the rest of the code changes are web_core changes. Did you mean to separate it like that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair question — it did not start that way, and the split is a leftover from a rebase.

The PR originally fixed the same bug in both: Dart's DataModel.set walked to the container for the last segment, found neither a map nor a list, and returned normally having written nothing, so the agent got no error and the value simply was not there. web_core had two variants of the same hole — a raw TypeError on a primitive root, and a root holding false, 0 or '' being silently discarded, because the guard was if (!this.data).

Then #2439 landed with the same Dart fix, at the same branch, raising the same A2uiDataError. I dropped my version of that hunk and kept its wording, which left what you are looking at: the web_core fix, plus the Dart tests, which now cover #2439's fix rather than my own. They assert the error type rather than the message, so they passed against that wording unmodified — and the case they pin, a write when the root itself is a primitive, has no test on main.

So the Dart test is deliberate but not original intent. Happy to split it into its own PR if you would rather review them separately; it is two tests and it moves cleanly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, as long as it is adding to the Dart test corpus, and not a mistake, then this is fine to keep here.

@gspencergoog

Copy link
Copy Markdown
Collaborator

Can you update the PR description with the actual cases addressed by this PR?

@diegolopezrm

Copy link
Copy Markdown
Contributor Author

It is there, but the table made it look like two separate changes when it is one branch, which is a fair thing to trip over.

-    if (!this.data) {
+    if (this.data === undefined || this.data === null) {
       this.data = {};
+    } else if (typeof this.data !== 'object') {
+      throw new A2uiDataError(
+        `Cannot set path '${path}': the data model root is a primitive value.`,
+        path,
+      );
     }

The old guard was a truthiness test, so a primitive root took one of two paths depending on whether it was truthy:

  • false, 0, '' — falsy, so !this.data was true and the root was replaced with {}, discarding the value.
  • 1, 'text', true — truthy, so it fell through to current[lastSegment] = value on a number or a string, which in a module throws TypeError: Cannot create property 'a' on number '1'.

Splitting the condition into "absent" and "primitive" handles both: only undefined/null gets a container, and everything else that is not an object raises A2uiDataError. Run against the built code:

set('/', 1)      then set('/a', 2) -> A2uiDataError, root still 1
set('/', "text") then set('/a', 2) -> A2uiDataError, root still "text"
set('/', true)   then set('/a', 2) -> A2uiDataError, root still true
set('/', false)  then set('/a', 2) -> A2uiDataError, root still false
set('/', 0)      then set('/a', 2) -> A2uiDataError, root still 0
set('/', "")     then set('/a', 2) -> A2uiDataError, root still ""

The set('/', 1) row is the one it('rejects writes when the root itself is a primitive') covers, and the falsy rows are covered by it('keeps a falsy primitive root instead of replacing it').

Rewriting the description now: it still describes the PR as it was before the rebase, when it also carried the Dart fix that #2439 has since landed.

@gspencergoog

Copy link
Copy Markdown
Collaborator

Could you please identify the agent, model, and prompt used to build this PR?

@diegolopezrm

Copy link
Copy Markdown
Contributor Author

Claude Opus 5, through Claude Code in its ultracode mode. Not a single prompt — a series across one session: locating the divergence, measuring it, writing the fix, and reviewing the result.

The factual claims were produced by running them rather than asserted: the 1,392-program differential behind #2498, the six-root table above, and the test counts. I reviewed and directed each step before it went up, and I am the one answering review here.

@gspencergoog

Copy link
Copy Markdown
Collaborator

Claude Opus 5, through Claude Code in its ultracode mode. Not a single prompt — a series across one session: locating the divergence, measuring it, writing the fix, and reviewing the result.

The factual claims were produced by running them rather than asserted: the 1,392-program differential behind #2498, the six-root table above, and the test counts. I reviewed and directed each step before it went up, and I am the one answering review here.

Cool, thanks, I was curious which you were using. Mostly I was curious if you would respond, or a bot. :-)

@diegolopezrm

Copy link
Copy Markdown
Contributor Author

Hahaha, I was watching to see where this was going 😂

@gspencergoog
gspencergoog merged commit 7bb2db9 into a2ui-project:main Sep 4, 2026
33 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants