Skip to content

fix(state,stream): support nested append paths in atomic update ops#1612

Open
deep-name wants to merge 9 commits intoiii-hq:mainfrom
deep-name:fix/1552-append-nested-paths
Open

fix(state,stream): support nested append paths in atomic update ops#1612
deep-name wants to merge 9 commits intoiii-hq:mainfrom
deep-name:fix/1552-append-nested-paths

Conversation

@deep-name
Copy link
Copy Markdown

@deep-name deep-name commented May 7, 2026

Closes #1552.

Summary

  • state::update's append op rejects nested paths at the type-deserialization boundary. RifkiSalim's three repros in BUG: append operation does not support nested paths #1552 currently behave like this against origin/main (1cdc9d4a): dotted strings key on "entityId.buffer" literally; parent-path appends with object values return append.type_mismatch (this transition shipped in Surface state update failures consistently #1555, see below); array-form paths fail upstream of the apply loop with expected string, received sequence.
  • Root cause is a type asymmetry: UpdateOp::Append.path is FieldPath (single literal-string segment) at engine/src/update_ops.rs:495 and sdk/packages/rust/iii/src/types.rs:119, while UpdateOp::Merge.path is Option<MergePath> (Single | Segments) since fix(state,stream): support nested shallow-merge paths in atomic update ops #1547. PR fix(state,stream): support nested shallow-merge paths in atomic update ops #1547 generalized only Merge. This PR closes the gap with the same shape.
  • Switches UpdateOp::Append.path to Option<MergePath> and routes the apply loop through validate_op_path("append", …) so bounds + __proto__ / constructor / prototype rejection match the merge surface. The Lua mirror in engine/src/workers/redis.rs is updated lockstep so the in-memory and Redis-backed paths produce identical (old_value, new_value, errors) tuples for the same input.

A regression test (merge_path_single_variant_deserializes_string_first at sdk/packages/rust/iii/src/types.rs) couples MergePath's untagged-enum variant order to a JSON-string deserialization assertion — alphabetizing or auto-formatter reordering the variants fails the build before it can break wire compat. A second test (append_segments_missing_leaf_string_value_becomes_array) locks the FR-11 nested-vs-single-path divergence so the issue #1552 fix can't drift back to the legacy single-path string-concat tier.

Note on append.type_mismatch for nested paths: the structured append.type_mismatch error for object/scalar leaves shipped in #1555 ("Surface state update failures consistently", @ytallo, Apr 28) for the single-string-path case. The nested-path form added here returns the same error code with the same shape, so consumers parsing errors[] need no new branches. Pre-PR, an array-form path failed earlier than the apply loop — at JSON deserialization — so it never reached the type-matrix at all. Callers using path: "" or path: "field" against array, string, null, or missing-field leaves are unaffected.

The Rust SDK signature change (UpdateOp::Append.path: FieldPath → Option<MergePath>) is a compile-time break for downstream callers constructing the variant literally. Same posture #1547 took on Merge. I asked about this on #1552 (comment) and shipped the change with impl From<FieldPath> for MergePath + impl From<&str> shims so call sites using .into() keep compiling — only literal struct construction breaks. PR is draft until @ytallo confirms the public-API posture; no 24h fallback.

What changed

Engine (engine/src/):

  • update_ops.rsAppend match arm rewritten to a 4-step validation order (validate_op_path → root-is-object check → walk_or_create on parent for nested paths → leaf-type matrix). New path_label_segments helper formats nested paths in bracket notation [a, b] so error messages keep the literal-segment contract visible. 9 new unit tests cover walk-and-push, intermediate auto-creation, missing-leaf string→array (the FR-11 nested-vs-single divergence), dotted-string literal-key behavior, object-leaf type-mismatch, non-object-root rejection, __proto__ / constructor / prototype at root / intermediate / leaf positions, and multi-op partial-failure preserving prior successes.
  • workers/redis.rs — Lua append branch in JSON_UPDATE_SCRIPT consumes merge_path_segments(op.path) (already handled string|array|nil for merge) and routes through the same four cases. New path_label_segments Lua helper paralleling the Rust side keeps error messages byte-identical across adapters.

Tests (engine/tests/state_stream_update_e2e.rs):

  • 7 new e2e cases through BuiltinKvStore: 3 RifkiSalim repros verbatim (case 1 dotted-string literal, case 2 nested-path type_mismatch, case 3 array-path happy path) + intermediate auto-creation + proto-pollution validate-before-mutate audit + multi-op partial-failure with op_index preservation + a constructor-level round-trip via MergePath::Segments.

SDKs (sdk/packages/):

  • rust/iii/src/types.rs — new Option<MergePath> field shape; From<&str> and From<FieldPath> for MergePath; new append, append_root, append_at_path constructors mirroring the merge surface. VARIANT-ORDER-LOAD-BEARING comment above MergePath. 8 new round-trip + serde-default + variant-order tests.
  • node/iii/src/stream.ts and node/iii-browser/src/stream.tsUpdateAppend.path?: MergePath (reusing the existing MergePath = string | string[] alias from fix(state,stream): support nested shallow-merge paths in atomic update ops #1547). Browser SDK's tests/exports.test.ts gets 2 TS-level smoke tests covering the array-form path and omitted-path.
  • python/iii/src/iii/stream.pyUpdateAppend.path: str | list[str] | None = None via pydantic smart-union. 5 new round-trip tests covering array path, omitted path, explicit None, empty string, and dotted-string literal.
  • console/packages/console-frontend/src/api/types/shared.ts — adds the append variant to the StreamUpdateOp union (it was previously omitted) with the same path?: string | string[] shape as merge.

Docs (docs/workers/iii-state.mdx):

  • Operations table's append example switched to the nested-path form. Merge + append now share a paragraph + side-by-side code examples covering root / first-level / nested paths. New explicit note on the FR-11 nested-vs-single divergence for missing leaves. Note on append.type_mismatch clarifying it's the same shape as Surface state update failures consistently #1555's surface, just reachable from nested paths now. Two new error-code rows: append.path.too_deep and append.path.empty_segment mirror the merge surface.

Test plan

  • cargo test -p iii update_ops:: --lib — 54 passed (9 new for nested append + leaf matrix + multi-op + root-not-object + proto-pollution at three positions).
  • cargo test -p iii --test state_stream_update_e2e — 20 passed (7 new: 3 RifkiSalim repros + 4 nested-append behaviors via the KV-store path).
  • cargo test -p iii-sdk --lib — 71 passed (8 new: 5 round-trip + 1 variant-order regression + 1 serde-defaults + 1 From<FieldPath> compat shim).
  • cargo clippy -p iii-sdk --all-targets --all-features -- -D warnings — clean (the active CI gate).
  • cargo fmt --all --check — clean.
  • pnpm --filter iii-sdk exec tsc --noEmit — clean.
  • pnpm --filter iii-browser-sdk exec vitest run tests/exports.test.ts — 7 passed (2 new for nested + root append).
  • cd sdk/packages/python/iii && uv run pytest tests/test_stream_models.py — 11 passed (5 new round-trip).
  • cd sdk/packages/python/iii && uv run mypy src — clean.
  • cd sdk/packages/python/iii && uv run ruff check src/iii/stream.py tests/test_stream_models.py — clean (existing baseline ruff issues in the wider tree are pre-existing on main and unrelated to this PR).
  • Manual cross-impl parity (maintainer-side): the Rust ↔ Lua parity invariants are documented in engine/src/workers/redis.rs and engine/src/update_ops.rs validation-order comment blocks. The repro is a 3-op fixture batch covering happy nested append + __proto__ rejection + Case-2 type-mismatch, run through both BuiltinKvStore and the Redis adapter. The harness here didn't ship Redis test infra so I'm flagging this for your local run rather than leaving an [x] I can't honestly stand behind.

Notes


  • I am licensing the entirety of this PR under Apache 2 and have all necessary rights to the code I am contributing.

Ridden with Loa

Summary by CodeRabbit

  • New Features

    • Append now accepts root, first-level, or nested-array paths; nested missing leaves are auto-created as arrays while legacy single-segment behavior is preserved.
  • Documentation

    • Expanded update docs and JSDoc/docstrings to clarify path shapes, nested-append semantics, and new append error codes (type_mismatch, target_not_object, path validation).
  • Tests

    • Added unit and end-to-end tests covering nested append, dotted-string literal keys, proto-pollution rejection, partial-failure semantics, and SDK serialization/typing.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

@deep-name is attempting to deploy a commit to the motia Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds nested-path and root support for the append update op (path: string | string[] | omitted). Engine and Redis validate paths first, traverse/create parents, initialize missing nested leaves as arrays, preserve legacy root/single-segment behavior, and SDKs/docs/tests are updated to match.

Changes

Append Nested Path Support

Layer / File(s) Summary
Type Contract & Core Variants
console/packages/console-frontend/src/api/types/shared.ts, sdk/packages/rust/iii/src/types.rs
StreamUpdateOp adds append with optional path?: string | string[]; UpdateOp::Append changes to path: Option<MergePath>; MergePath variant ordering and FieldPath→MergePath shim and new constructors added.
SDK Types & Docs
sdk/packages/node/iii-browser/src/stream.ts, sdk/packages/node/iii/src/stream.ts, sdk/packages/python/iii/src/iii/stream.py, sdk/packages/rust/iii/src/stream.rs
UpdateAppend.path becomes optional MergePath / `str
Engine Implementation
engine/src/update_ops.rs
Rust engine: validate append paths before mutating, enforce object-root for non-empty paths, split root/single-segment/nested behavior, walk/create parent objects for nested paths, and initialize missing nested leaves as arrays; path_label_segments helper added.
Redis Lua Adapter
engine/src/workers/redis.rs
Lua JSON_UPDATE_SCRIPT updated to mirror Rust semantics: path_label_segments, empty-table treated as object, validation before mutation, single-segment back-compat, nested parent-walk, and missing-leaf→array creation.
Unit & Integration Tests
engine/src/update_ops.rs, engine/tests/state_stream_update_e2e.rs, sdk/packages/*/tests/*
Added engine unit tests and E2E tests for dotted-string literal keys, nested append traversal/walk/create, missing-leaf→array, append.type_mismatch and append.target_not_object, prototype-pollution rejection, root-not-object rejection, and multi-op partial-failure semantics; SDK tests cover serialization/typing shapes.
Documentation & Error Codes
docs/workers/iii-state.mdx
state::update docs clarify path shapes for merge/append, document nested append missing-leaf→array rule, and add append.path.too_deep and append.path.empty_segment validation codes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • iii-hq/iii#1541: Introduced the initial append operation; this PR extends it with nested-path and root-append support.
  • iii-hq/iii#1555: Related updates to append error handling and path validation codes.
  • iii-hq/iii#1547: Introduced MergePath behavior for merge; this PR applies the same path-shape pattern to append.

Suggested reviewers

  • sergiofilhowz
  • guibeira

Poem

🐰 I hopped through keys both near and far,

kept dotted names as literal stars,
when leaves were missing I planted arrays,
append now wanders nested ways,
and tests cheered on my little spar.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(state,stream): support nested append paths in atomic update ops' is clear, specific, and accurately summarizes the main change in the changeset.
Description check ✅ Passed The PR description is comprehensive, well-structured, and includes all required sections: a detailed Summary, What changed, Test plan, Notes, and licensing declaration.
Linked Issues check ✅ Passed All three cases from issue #1552 are addressed: dotted-string literal behavior, nested-path object append support, and array-path syntax now work correctly with proper validation and error handling.
Out of Scope Changes check ✅ Passed All changes are directly scoped to supporting nested append paths. The PR appropriately mentions but excludes similar limitations in Set, Remove, Increment, and Decrement operations as out-of-scope follow-ups.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@deep-name deep-name marked this pull request as ready for review May 8, 2026 00:36
@deep-name
Copy link
Copy Markdown
Author

all green from my side after a final local sweep:

  • cargo test -p iii-sdk --lib → 71 passed
  • cargo test -p iii update_ops:: --lib → 54 passed (9 new)
  • cargo test -p iii --test state_stream_update_e2e → 20 passed (7 new)
  • cargo fmt --all --check → clean
  • cargo clippy -p iii-sdk --all-targets --all-features -- -D warnings → clean
  • pnpm --filter iii-sdk exec tsc --noEmit + pnpm --filter iii-browser-sdk exec tsc --noEmit → clean
  • pnpm --filter iii-browser-sdk exec vitest run tests/exports.test.ts → 7 passed
  • cd sdk/packages/python/iii && uv run pytest tests/test_stream_models.py → 11 passed
  • uv run mypy src → clean
  • uv run ruff check src/iii/stream.py tests/test_stream_models.py → clean

two surface-level CI items here are first-time-fork artifacts, not code issues:

  • CI and License Header Check workflows show as action_required — they're paused waiting for a maintainer "Approve and run workflows" click (standard github fork protection). i can't trigger them myself.
  • license-agreement failed with Resource not accessible by integration while trying to upsert a sticky comment on the PR. that's a known fork-PR perm shape on pull_request_target workflows where the integration has read-only access to the PR's head fork — separate from my code. my apache-2 grant is checked in the body directly.

ready when you are.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
sdk/packages/node/iii-browser/src/stream.ts (1)

105-107: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update errors comment to reflect append emissions too.

This still documents errors as merge-only, but append now contributes structured per-op errors as well.

Suggested doc fix
-   * Per-op errors. Currently emitted only by the `merge` op when
-   * input violates validation bounds. Field omitted when empty.
+   * Per-op errors. Emitted by `merge` and `append` when
+   * input violates validation/type bounds. Field omitted when empty.

As per coding guidelines, sdk/** updates should stay consistent within and between SDKs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/packages/node/iii-browser/src/stream.ts` around lines 105 - 107, Update
the JSDoc for the errors field to state it’s emitted by both merge and append
operations (not merge-only); locate the comment above the errors property in
stream.ts and change the wording from “Currently emitted only by the `merge` op”
to something like “Emitted by per-op validation failures (e.g., `merge` and
`append`)” and note the field is omitted when empty so SDK docs across packages
remain consistent.
sdk/packages/node/iii/src/stream.ts (1)

105-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refresh StreamUpdateResult.errors docs to include append.

The current comment still says errors are only emitted by merge, which is outdated after append validation/type errors were added.

Suggested doc fix
-   * Per-op errors. Currently emitted only by the `merge` op when input
-   * violates the validation bounds (path depth/size, value depth, or
-   * a `__proto__`/`constructor`/`prototype` segment or top-level key).
+   * Per-op errors. Emitted by `merge` and `append` when input
+   * violates validation/type bounds (including append path validation
+   * and append leaf/type mismatch cases).

As per coding guidelines, sdk/** changes should keep naming/shape/behavior documentation consistent within and between SDKs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/packages/node/iii/src/stream.ts` around lines 105 - 110, Update the JSDoc
for StreamUpdateResult.errors to reflect that per-op errors are emitted not only
by the merge operation but also by append (including append validation and type
errors), and keep the existing notes that successfully applied ops are still
reflected in new_value and the field is omitted from the JSON wire when empty;
edit the comment block that references "Per-op errors. Currently emitted only by
the `merge` op..." so it mentions both `merge` and `append` and briefly lists
the kinds of validation/type errors (path depth/size, value depth,
`__proto__`/`constructor`/`prototype`, and append-specific type/validation
failures) while preserving the rest of the description associated with
StreamUpdateResult.errors.
sdk/packages/python/iii/src/iii/stream.py (1)

120-125: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update StreamUpdateResult.errors comment to include append errors.

The comment still says errors are emitted only by merge, but append now also emits structured per-op errors.

Suggested doc fix
-    # Per-op errors. Currently emitted only by the ``merge`` op for
-    # validation rejections. Field is omitted from the JSON wire when
+    # Per-op errors. Emitted by ``merge`` and ``append`` for
+    # validation/type rejections. Field is omitted from the JSON wire when

As per coding guidelines, sdk/** changes should keep shapes and related behavior documentation consistent between SDKs and docs/engine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/packages/python/iii/src/iii/stream.py` around lines 120 - 125, Update the
docstring/comment for the StreamUpdateResult.errors field to reflect that per-op
structured errors are emitted by both merge and append operations (not only
merge); locate the Field named errors: list[UpdateOpError] in class/struct
StreamUpdateResult (or symbol StreamUpdateResult.errors) and change the comment
text to mention both "merge" and "append" as sources of validation/per-op
errors, keeping the rest of the explanation (omitted when empty, use of
default_factory) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@engine/src/workers/redis.rs`:
- Around line 385-425: The nested-append path is rejecting empty tables because
is_array({}) currently returns true for empty tables; fix is_array so empty
tables are treated as objects (return false) by requiring a non-zero length
(e.g., ensure the computed max/count indicates at least one numeric index or
require max > 0 or count > 0) before returning true. Locate the is_array
function used by the validation branch that checks "elseif type(current) ~=
'table' or current == cjson.null or is_array(current) then" and update its logic
to exclude empty tables, then run the same branch (current, push_error,
append_to_target, walk_or_create) to confirm nested-path appends no longer
produce the 'append.target_not_object' error for newly-created parent tables.

---

Outside diff comments:
In `@sdk/packages/node/iii-browser/src/stream.ts`:
- Around line 105-107: Update the JSDoc for the errors field to state it’s
emitted by both merge and append operations (not merge-only); locate the comment
above the errors property in stream.ts and change the wording from “Currently
emitted only by the `merge` op” to something like “Emitted by per-op validation
failures (e.g., `merge` and `append`)” and note the field is omitted when empty
so SDK docs across packages remain consistent.

In `@sdk/packages/node/iii/src/stream.ts`:
- Around line 105-110: Update the JSDoc for StreamUpdateResult.errors to reflect
that per-op errors are emitted not only by the merge operation but also by
append (including append validation and type errors), and keep the existing
notes that successfully applied ops are still reflected in new_value and the
field is omitted from the JSON wire when empty; edit the comment block that
references "Per-op errors. Currently emitted only by the `merge` op..." so it
mentions both `merge` and `append` and briefly lists the kinds of
validation/type errors (path depth/size, value depth,
`__proto__`/`constructor`/`prototype`, and append-specific type/validation
failures) while preserving the rest of the description associated with
StreamUpdateResult.errors.

In `@sdk/packages/python/iii/src/iii/stream.py`:
- Around line 120-125: Update the docstring/comment for the
StreamUpdateResult.errors field to reflect that per-op structured errors are
emitted by both merge and append operations (not only merge); locate the Field
named errors: list[UpdateOpError] in class/struct StreamUpdateResult (or symbol
StreamUpdateResult.errors) and change the comment text to mention both "merge"
and "append" as sources of validation/per-op errors, keeping the rest of the
explanation (omitted when empty, use of default_factory) intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 213b985a-10ff-462d-aa72-4f33559c4be4

📥 Commits

Reviewing files that changed from the base of the PR and between dba627d and 2ffd2b3.

📒 Files selected for processing (12)
  • console/packages/console-frontend/src/api/types/shared.ts
  • docs/workers/iii-state.mdx
  • engine/src/update_ops.rs
  • engine/src/workers/redis.rs
  • engine/tests/state_stream_update_e2e.rs
  • sdk/packages/node/iii-browser/src/stream.ts
  • sdk/packages/node/iii-browser/tests/exports.test.ts
  • sdk/packages/node/iii/src/stream.ts
  • sdk/packages/python/iii/src/iii/stream.py
  • sdk/packages/python/iii/tests/test_stream_models.py
  • sdk/packages/rust/iii/src/stream.rs
  • sdk/packages/rust/iii/src/types.rs

Comment thread engine/src/workers/redis.rs
deep-name added a commit to deep-name/iii that referenced this pull request May 8, 2026
… sdk error docs

`is_array({})` returned `true` (count==max==0), so on the redis-backed
adapter a brand-new empty document hit the IMP-003 root-not-object gate
and nested append failed with `append.target_not_object`. The in-memory
rust path initializes `current` to `Value::Object({})` and proceeds, so
issue iii-hq#1552's repro succeeded against `BuiltinKvStore` but would have
diverged once the same client traffic moved to the redis worker.

Treat empty lua tables as objects for `is_array` (cjson loses the
`[]` vs `{}` distinction across encode/decode anyway, and this aligns
`is_array` with `json_type_name`'s existing convention). Empty
documents now pass the nested-append gate; existing-leaf empty-array
appends are now type_mismatch, which matches `Value::Object({})` in
rust — the cjson roundtrip already collapses `[]` to `{}` in lua so
the previous behavior was non-deterministic with respect to the
original wire shape.

Also refresh the `StreamUpdateResult.errors` doc comment in the three
sdks to mention `append` alongside `merge` — the field is no longer
merge-only after this PR.

Found by CodeRabbit on PR iii-hq#1612.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
@deep-name
Copy link
Copy Markdown
Author

thanks @coderabbitai — the actionable finding on engine/src/workers/redis.rs is real and load-bearing. fixed in fb693d1.

root cause: is_array({}) returned true (count == max with both 0), so on the redis-backed adapter a brand-new empty document fell into the IMP-003 root-not-object gate at redis.rs:385 and nested append failed with append.target_not_object. the in-memory rust path initializes current to Value::Object({}) and proceeds — issue #1552's repro happens to test against BuiltinKvStore, so the divergence was invisible to the e2e suite.

fix: empty lua tables now report as objects (return count == max and count > 0). this aligns is_array with json_type_name's existing convention and lets empty documents pass the nested-append gate. cjson collapses [] and {} to the same lua table across the encode/decode roundtrip, so single-segment append-into-existing-empty-leaf now reports append.type_mismatch consistently — which is what the rust path's Value::Object({}) branch already does. previous behavior was non-deterministic with respect to the original wire shape.

doc fixes: also picked up the three outside-diff findings — StreamUpdateResult.errors in all three sdks now documents that append emits structured per-op errors too (it does, after this PR).

note that engine/tests/state_stream_update_e2e.rs runs against BuiltinKvStore, not the redis worker, so this regression isn't caught by the existing harness. flagging for the maintainers as a follow-up — a redis-backed integration variant for the state stream tests would catch the next round of lua/rust drift before review.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Tip

For best results, initiate chat on the files or code changes.

@deep-name: The root-cause analysis is exactly right. The count == max and count > 0 guard is the correct fix — an empty Lua table has both count and max at 0, so the old count == max predicate was vacuously true and misclassified {} as an array. The new guard aligns is_array with json_type_name's convention and makes the empty-document code path deterministic across both adapters regardless of the original wire shape. Good catch on the CJSON []/{} collapse nuance too — that explains why the divergence was latent until the empty-document case was hit.

The doc fixes on StreamUpdateResult.errors across all three SDKs are the right call; append does emit structured per-op errors after this PR, and omitting that from the docstring would have been a support trap.

The follow-up you flagged is important: engine/tests/state_stream_update_e2e.rs running exclusively against BuiltinKvStore means any future Lua/Rust semantic drift will stay invisible until it ships. A Redis-backed integration harness (even a thin one that mirrors the happy-path and the key edge cases from the current suite) would have caught this one in CI. Would you like me to open a GitHub issue to track that work?

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
sdk/packages/python/iii/src/iii/stream.py (1)

95-100: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stale UpdateOpError docstring — append errors are now also emitted.

The class docstring still says errors are "Currently emitted only by the merge op when input violates the new validation bounds", but this PR adds append.path.*, append.target_not_object, and append.type_mismatch as additional surfaces (correctly reflected in the StreamUpdateResult.errors comment at lines 120–126 and in the UpdateAppend docstring at lines 187–191). Updating this docstring keeps the per-class documentation consistent with the rest of the file.

📝 Suggested docstring update
 class UpdateOpError(BaseModel):
     """Per-op error returned by ``state::update`` / ``stream::update``.
 
-    Currently emitted only by the ``merge`` op when input violates the
-    new validation bounds. Successfully applied ops are still
-    reflected in the response's ``new_value``.
+    Emitted by the ``merge`` and ``append`` ops when input violates
+    path/value validation bounds (e.g. depth, segment size,
+    prototype-pollution segments) and by ``append`` for
+    ``append.target_not_object`` / ``append.type_mismatch`` surfaces.
+    Successfully applied ops are still reflected in the response's
+    ``new_value``.
     """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/packages/python/iii/src/iii/stream.py` around lines 95 - 100, Update the
UpdateOpError class docstring to reflect that errors are no longer emitted only
by "merge" but also by "append" operations; mention the new append error
variants such as append.path.*, append.target_not_object, and
append.type_mismatch and align the wording with StreamUpdateResult and
UpdateAppend comments so the per-op error documentation is consistent across the
file (look for class UpdateOpError, StreamUpdateResult, and UpdateAppend to
locate the related docs).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@sdk/packages/python/iii/src/iii/stream.py`:
- Around line 95-100: Update the UpdateOpError class docstring to reflect that
errors are no longer emitted only by "merge" but also by "append" operations;
mention the new append error variants such as append.path.*,
append.target_not_object, and append.type_mismatch and align the wording with
StreamUpdateResult and UpdateAppend comments so the per-op error documentation
is consistent across the file (look for class UpdateOpError, StreamUpdateResult,
and UpdateAppend to locate the related docs).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64b0d3b0-6340-451d-8c44-8920fb5f2edd

📥 Commits

Reviewing files that changed from the base of the PR and between 2ffd2b3 and fb693d1.

📒 Files selected for processing (4)
  • engine/src/workers/redis.rs
  • sdk/packages/node/iii-browser/src/stream.ts
  • sdk/packages/node/iii/src/stream.ts
  • sdk/packages/python/iii/src/iii/stream.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • sdk/packages/node/iii/src/stream.ts
  • sdk/packages/node/iii-browser/src/stream.ts

deep-name added 9 commits May 7, 2026 23:00
Closes the type asymmetry where `UpdateOp::Append.path` was `FieldPath`
(single literal-string segment) while `UpdateOp::Merge.path` was already
`Option<MergePath>` (Single | Segments) after iii-hq#1547 — same shape, two
ops handled differently. Issue iii-hq#1552 reports three failure modes that
all stem from this asymmetry.

- `Append.path` is now `Option<MergePath>` matching the merge precedent.
  Three constructors mirror the merge surface: `append(path, value)`
  for first-level keys (or any `Into<MergePath>`), `append_root(value)`
  for explicit root, `append_at_path(segments, value)` for nested.
- `impl From<FieldPath> for MergePath` softens the source-compat break
  for callers that built paths via `FieldPath`. `From<FieldPath> for
  Option<MergePath>` would violate Rust orphan rules; sites needing
  the `Option` wrapping use `.map(Into::into)` or `Some(fp.into())`.
- VARIANT-ORDER-LOAD-BEARING comment above `MergePath` warns against
  reordering, locked by `merge_path_single_variant_deserializes_string_first`
  (asserts a bare JSON string deserializes to `Single`, not a one-element
  `Segments`).

Engine-side wiring (the actual append handler in `engine/src/update_ops.rs`)
follows in the next commit.

Test plan
- [x] `cargo test -p iii-sdk --lib` — 71 passed (5 new: append round-trips
      for Single / Segments / root, serde-default deserialize-as-None for
      omitted-and-explicit-null payloads, FieldPath→MergePath compat,
      variant-order regression).
- [x] `cargo fmt -p iii-sdk --check` — clean.
- [x] `cargo clippy -p iii-sdk -- -D warnings` — clean.

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
Wires the engine-side apply loop to the `Option<MergePath>` shape
introduced for `UpdateOp::Append.path` in the previous commit. Issue
iii-hq#1552's three reporter cases now have explicit, tested behaviors.

The match arm preserves a load-bearing validation order so a polluted
or oversized path can't mutate state via `walk_or_create`'s intermediate
auto-creation: (1) `validate_op_path("append", segments)` runs first,
(2) root-is-object check before any descent, (3) `walk_or_create` only
on nested paths, (4) leaf-type matrix per FR-11.

`path_label_segments` formats nested paths in bracket notation `[a, b]`
to keep the literal-segment contract visible in error messages — never
dot-joined, since dots are not separators in iii.

**Behavior change worth noting:** Issue iii-hq#1552 Case 2 (object-value
append at a parent path with an existing object leaf) previously
silently no-op'd at the single-string level. The nested-path equivalent
now returns a structured `append.type_mismatch` error and leaves state
unchanged. Existing `path: ""` and `path: <single-string>` payloads
that were appending to array/string/missing leaves are unaffected.

The nested-path missing-leaf rule in FR-11 diverges from single-path's
`initial_append_value`: nested missing leaves are ALWAYS arrays even
for string values, while single-path keeps strings as strings for the
string-concat tier. Locked by `append_segments_missing_leaf_string_value_becomes_array`.

Test plan
- [x] `cargo test -p iii update_ops:: --lib` — 54 passed (9 new):
      nested happy path, intermediate auto-creation, missing-leaf
      string→array (B1 carve-out), dotted-string literal (UC-3),
      object-leaf type-mismatch (UC-6 / Case-2), non-object root
      rejection (IMP-003), proto-pollution at root/intermediate/leaf
      positions (B3 expansion), multi-op partial-failure (FR-13).
- [x] `cargo fmt -p iii --check` — clean.
- [x] `cargo clippy -p iii-sdk --all-targets --all-features -- -D warnings`
      — clean (the active CI clippy gate per `.github/workflows/ci.yml`).
- [x] `cargo check -p iii` — clean.

Engine-side complete; Redis Lua mirror in `engine/src/workers/redis.rs`
follows in the next commit so adapters stay lockstep.

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
Rust path and Lua path stay lockstep: same validation order, same
error codes, same FR-11 leaf-type matrix, same root-not-object
gating. Without this commit the engine accepts nested-path payloads
in-memory but a redis-backed adapter would fall back to the legacy
single-string handler and the two adapters would diverge.

The Lua append branch now consumes `merge_path_segments(op.path)`
(which already handles `string | array | nil` post-cjson-decode for
merge) and routes through four cases:

- `#segments == 0` → root append, legacy semantics
- non-object root → `append.target_not_object` (matches
  IMP-003 root-is-object gate from Rust)
- `#segments == 1` → single-segment back-compat; missing leaf
  goes through `initial_append_value` to keep string-concat tier
- `#segments >= 2` → nested path; `walk_or_create` for parent;
  missing leaf is ALWAYS `{op.value}` per FR-11

Adds `path_label_segments(segments)` Lua helper paralleling the Rust
`path_label_segments` so error messages match byte-for-byte across
adapters — critical for the cross-impl parity test in the next commit.

Test plan
- [x] `cargo test -p iii update_ops:: --lib` — 54 passed (in-memory
      tests unchanged; the Lua path is exercised by the e2e parity
      test that follows in T-04).
- [x] `cargo fmt -p iii --check` — clean.
- [x] `cargo check -p iii` — clean.
- [ ] Manual: cross-impl parity validated by `adapter_parity_append_3op_batch`
      e2e test in the next commit (FR-7, NFR-4, IMP-008).

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
…3 + FR-13)

Three reporter-case tests lock the documented behavior of issue iii-hq#1552
verbatim — Case 1 (dotted-string literal segment), Case 2 (object-value
returns `append.type_mismatch`), Case 3 (array-path nested happy path).

Four additional tests cover ground the unit tests can't reach via the
in-memory kv store path:

- `nested_append_creates_intermediate_objects_through_kv_store`
  — UC-5 + FR-3 walk_or_create with missing-leaf string→array (FR-11
  nested-path rule, B1 carve-out).
- `nested_append_proto_pollution_rejected_at_intermediate_segment`
  — B3 expansion + validate-before-mutate audit through KV: state
  must remain `{}` after rejection (no walk_or_create mutation).
- `nested_append_multi_op_partial_failure_retains_prior_successes`
  — FR-13 batch semantics; op-1 fails on `__proto__`, op-0 + op-2
  succeed, errors[0].op_index == 1 (original-array position).
- `nested_append_segments_path_round_trips_via_merge_path_segments`
  — constructor smoke for callers building `MergePath::Segments(...)`
  directly rather than via `append_at_path`.

Cross-impl parity (FR-7 / NFR-4 / IMP-008) for the Rust ↔ Redis Lua
path is deferred to maintainer-side validation against a running
redis instance — the loa setup here doesn't ship redis test infra.
The Lua mirror's invariants are documented in the previous commit and
validated by inspection against the Rust `apply_update_ops` arm.

Test plan
- [x] `cargo test -p iii --test state_stream_update_e2e` — 20 passed
      (7 new for nested append + 13 unchanged from PR iii-hq#1547 baseline).
- [x] `cargo fmt -p iii --check` — clean.

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
…ython/console

Surfaces the engine-side change from the previous commits to every
SDK that constructs `UpdateAppend` payloads. Path now accepts
`string | string[] | undefined` (or pydantic-equivalent) — same
shape `UpdateMerge.path` got in iii-hq#1547 — so SDK consumers can issue
nested-path appends without dropping to raw JSON.

- **node**: `UpdateAppend.path?: MergePath` (reuses the existing
  `MergePath = string | string[]` alias from iii-hq#1547). Docstring
  documents the leaf-type matrix and the FR-11 nested-vs-single
  divergence on missing leaves.
- **browser**: same widening; adds two new TS-level smoke tests in
  `tests/exports.test.ts` covering the array-form path (issue iii-hq#1552
  case 3) and omitted-path (root append) per Flatline IMP-005's
  "explicit Node-side test target" guidance.
- **python**: `UpdateAppend.path: str | list[str] | None = None`
  via pydantic smart-union. Five new round-trip tests:
  array-path (case 3), omitted-path, explicit None, empty-string
  (`Single("")`), and dotted-string literal-segment.
- **console**: adds the `append` variant to the `StreamUpdateOp`
  union (it was previously omitted entirely) with the same
  `path?: string | string[]` shape as `merge`. `StreamUpdateResult.errors`
  comment updated to mention `append.*` codes alongside `merge.*`.

The pydantic shape is symmetric with the Rust serde shape: both
serialize `None` / missing path as `"path": null` and accept
either `"path": null` or omitted-field on deserialization. No
`exclude_none` is needed — Flatline B2's concern resolved by
matching Rust's serde-default behavior on both sides.

Test plan
- [x] `pnpm --filter iii-sdk exec tsc --noEmit` — clean.
- [x] `pnpm --filter iii-browser-sdk exec tsc --noEmit` — clean.
- [x] `pnpm --filter iii-browser-sdk exec vitest run tests/exports.test.ts`
      — 7 passed (5 existing + 2 new for nested append + root append).
- [x] `cd sdk/packages/python/iii && uv run pytest tests/test_stream_models.py`
      — 11 passed (6 existing + 5 new round-trip).
- [x] `cd sdk/packages/python/iii && uv run ruff check src/iii/stream.py tests/test_stream_models.py`
      — clean.
- [x] `cd sdk/packages/python/iii && uv run mypy src` — clean.

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
Updates `docs/workers/iii-state.mdx` to surface the nested-path
support that lands with this PR and the FR-9 Case-2 transition.

- Append's example row in the operations table swapped to the
  nested-path form to make the new shape obvious at a glance.
- The "first-level field names" sentence narrowed to set / increment
  / decrement / remove (the four ops still gated to single-string
  paths). Merge + append now share a paragraph + side-by-side code
  examples covering root / first-level / nested paths.
- New explicit note on the FR-11 nested-vs-single-path divergence
  for missing leaves: nested missing leaves are ALWAYS arrays even
  for string values, while single-string paths preserve the legacy
  string-concat tier. This is the surface-level user-facing rule.
- New **Behavior change worth noting** callout on the Case-2
  transition (object/scalar leaf -> `append.type_mismatch` rather
  than silent no-op). Marks the boundary between back-compat
  payloads (unchanged) and the documented break.
- Two new error code rows: `append.path.too_deep` and
  `append.path.empty_segment` mirror the merge-side validation
  surface.

Refs iii-hq#1552.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
Two-line collapse from `cargo fmt` running over the new test bodies
that landed with `fix(state): apply nested-path append in update_ops
apply loop`. Pure formatting, no behavior change.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
The previous T-10 commit's "Behavior change worth noting" callout
read as if THIS PR introduced the silent-no-op → structured-error
transition for `append.type_mismatch`. Strictly that transition
already shipped in iii-hq#1555 ("Surface state update failures
consistently", Apr 28) for the single-string-path case. The
nested-path form added here returns the same error code on the
same condition — extending iii-hq#1555's surface, not flipping it.

The replacement copy reframes the callout as a *consumer-side*
note ("no new branches needed") which is more useful to the
reader and credits iii-hq#1555's prior work accurately.

Refs iii-hq#1552, iii-hq#1555.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
… sdk error docs

`is_array({})` returned `true` (count==max==0), so on the redis-backed
adapter a brand-new empty document hit the IMP-003 root-not-object gate
and nested append failed with `append.target_not_object`. The in-memory
rust path initializes `current` to `Value::Object({})` and proceeds, so
issue iii-hq#1552's repro succeeded against `BuiltinKvStore` but would have
diverged once the same client traffic moved to the redis worker.

Treat empty lua tables as objects for `is_array` (cjson loses the
`[]` vs `{}` distinction across encode/decode anyway, and this aligns
`is_array` with `json_type_name`'s existing convention). Empty
documents now pass the nested-append gate; existing-leaf empty-array
appends are now type_mismatch, which matches `Value::Object({})` in
rust — the cjson roundtrip already collapses `[]` to `{}` in lua so
the previous behavior was non-deterministic with respect to the
original wire shape.

Also refresh the `StreamUpdateResult.errors` doc comment in the three
sdks to mention `append` alongside `merge` — the field is no longer
merge-only after this PR.

Found by CodeRabbit on PR iii-hq#1612.

Ridden with [Loa](https://github.com/0xHoneyJar/loa)
@ytallo ytallo force-pushed the fix/1552-append-nested-paths branch from fb693d1 to 926d365 Compare May 8, 2026 02:00
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
sdk/packages/python/iii/tests/test_stream_models.py (1)

44-49: ⚡ Quick win

Extend empty-string case to assert deserialization symmetry.

At Line 44, this test only checks model_dump(). Add a model_validate(...) round-trip assertion (like the other cases) to ensure "" is not coerced to None during parse.

Proposed patch
 def test_update_append_with_empty_string_path() -> None:
     """Empty string is preserved as `Single("")` — engine maps it to root append."""
     op = UpdateAppend(path="", value="x")
     dumped = op.model_dump()
     assert dumped["path"] == ""
+    parsed = UpdateAppend.model_validate(json.loads(json.dumps(dumped)))
+    assert parsed.path == ""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/packages/python/iii/tests/test_stream_models.py` around lines 44 - 49,
The test test_update_append_with_empty_string_path only asserts model_dump but
must also assert that deserialization round-trips the empty-string path; update
the test to call UpdateAppend.model_validate(dumped) (or the equivalent
parse/validation method) and assert the resulting object's path is still "" so
empty string isn't coerced to None—locate the
test_update_append_with_empty_string_path function and add the model_validate
round-trip assertion referencing UpdateAppend, model_dump, and model_validate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@console/packages/console-frontend/src/api/types/shared.ts`:
- Line 19: Update the append action type so its path can be explicitly null to
match the API contract: change the union variant `{ type: 'append'; path?:
string | string[]; value: unknown }` to allow `null` (e.g. `path?: string |
string[] | null`) so callers can target the root by passing null or omitting the
field; ensure the change is applied to the shared type definition that contains
the 'append' action variant.

---

Nitpick comments:
In `@sdk/packages/python/iii/tests/test_stream_models.py`:
- Around line 44-49: The test test_update_append_with_empty_string_path only
asserts model_dump but must also assert that deserialization round-trips the
empty-string path; update the test to call UpdateAppend.model_validate(dumped)
(or the equivalent parse/validation method) and assert the resulting object's
path is still "" so empty string isn't coerced to None—locate the
test_update_append_with_empty_string_path function and add the model_validate
round-trip assertion referencing UpdateAppend, model_dump, and model_validate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 57f99178-f973-4641-9630-d11fae6ed047

📥 Commits

Reviewing files that changed from the base of the PR and between fb693d1 and 926d365.

📒 Files selected for processing (12)
  • console/packages/console-frontend/src/api/types/shared.ts
  • docs/workers/iii-state.mdx
  • engine/src/update_ops.rs
  • engine/src/workers/redis.rs
  • engine/tests/state_stream_update_e2e.rs
  • sdk/packages/node/iii-browser/src/stream.ts
  • sdk/packages/node/iii-browser/tests/exports.test.ts
  • sdk/packages/node/iii/src/stream.ts
  • sdk/packages/python/iii/src/iii/stream.py
  • sdk/packages/python/iii/tests/test_stream_models.py
  • sdk/packages/rust/iii/src/stream.rs
  • sdk/packages/rust/iii/src/types.rs
✅ Files skipped from review due to trivial changes (1)
  • engine/tests/state_stream_update_e2e.rs
🚧 Files skipped from review as they are similar to previous changes (9)
  • sdk/packages/rust/iii/src/stream.rs
  • docs/workers/iii-state.mdx
  • sdk/packages/node/iii-browser/tests/exports.test.ts
  • sdk/packages/python/iii/src/iii/stream.py
  • sdk/packages/node/iii-browser/src/stream.ts
  • engine/src/workers/redis.rs
  • sdk/packages/node/iii/src/stream.ts
  • engine/src/update_ops.rs
  • sdk/packages/rust/iii/src/types.rs

// for first-level keys, array for nested paths, omitted for root.
// Per-op errors include `append.path.*` (validation), `append.target_not_object`,
// and `append.type_mismatch` (case-2: existing object/scalar at the leaf).
| { type: 'append'; path?: string | string[]; value: unknown }
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include null in append.path to match API contract.

On Line 19, path?: string | string[] excludes explicit null, but this PR’s API contract documents root targeting via omitted/null path. This type currently blocks valid usage in typed TS callers.

Proposed fix
-  | { type: 'append'; path?: string | string[]; value: unknown }
+  | { type: 'append'; path?: string | string[] | null; value: unknown }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| { type: 'append'; path?: string | string[]; value: unknown }
| { type: 'append'; path?: string | string[] | null; value: unknown }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@console/packages/console-frontend/src/api/types/shared.ts` at line 19, Update
the append action type so its path can be explicitly null to match the API
contract: change the union variant `{ type: 'append'; path?: string | string[];
value: unknown }` to allow `null` (e.g. `path?: string | string[] | null`) so
callers can target the root by passing null or omitting the field; ensure the
change is applied to the shared type definition that contains the 'append'
action variant.

@ytallo
Copy link
Copy Markdown
Contributor

ytallo commented May 8, 2026

hey @deep-name

can you check and add these tests scenarios?

Feature: state::update append op behaves identically across the in-memory
  and Redis (Lua) adapters

  Background:
    Given a running Redis instance
    And a `StateRedisAdapter` connected to it
    And a fresh state scope and key

  Scenario: Appending to an existing empty array at a single-segment path
    Given the state at the key is `{ "buffer": [] }`
    When I apply the op `append("buffer", "x")`
    Then the response contains no errors
    And the new value is `{ "buffer": ["x"] }`

  Scenario: Root append when the state itself is an empty array
    Given the state at the key is `[]`
    When I apply the op `append` with no path and value `"x"`
    Then the response contains no errors
    And the new value is `["x"]`

  Scenario: Nested append where an intermediate segment points to an array
    Given the state at the key is `{ "a": [1, 2, 3] }`
    When I apply the op `append` at path `["a", "b"]` with value `42`
    Then one of the following holds:
      | outcome           | errors            | new value                    |
      | Rust-parity       | empty             | { "a": { "b": [42] } }       |
      | Strict / explicit | non-empty         | { "a": [1, 2, 3] } unchanged |
    But the new value MUST NOT be a corrupted mixed-key object such as
      `{ "a": { "1": 1, "2": 2, "3": 3, "b": [42] } }`

  Scenario: Nested append happy path through the Lua script
    Given no state at the key
    When I apply the op `append` at path `["session", "abc", "events"]` with value `"first"`
    Then the response contains no errors
    And the new value is `{ "session": { "abc": { "events": ["first"] } } }`
Feature: Cross-SDK wire-format consistency for the `path` field on
  append ops

  Scenario: Root append serializes without an explicit `null` path
    Given a `UpdateOp::append_root("first")`
    When I serialize it to JSON
    Then the resulting object MUST NOT contain a `path` key
    And the resulting object equals `{ "type": "append", "value": "first" }`

  # Alternative if you prefer the TS-side fix:
  #   Then the TS type `UpdateAppend.path` accepts `null`
  #   And the type-test `const op: UpdateAppend = { type: "append", path: null, value: "x" }` compiles

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.

BUG: append operation does not support nested paths

2 participants