Skip to content

feat: keep provenance across computed keys resolved at render time - #61

Merged
stackloomdev merged 1 commit into
mainfrom
feat/dynamic-key-provenance
Jul 28, 2026
Merged

stackloomdev merged 1 commit into
mainfrom
feat/dynamic-key-provenance

Conversation

@stackloomdev

Copy link
Copy Markdown
Owner

Follow-up to #60. Same gap in the evidence chain, reached a different way.

The gap

staticMemberInfo abandoned a member chain the moment it hit a computed key that was not a literal, and wrapCapturedMember returned false with it. So these emitted no hint at all:

<td>{row[columnId]}</td>          // table cell by column id
<span>{data[key].status}</span>   // dynamic field
<span>{items[index].name}</span>  // list index in a variable

The value is a primitive, primitives have no identity, and without an access path there was nothing to recover provenance from. Exactly the failure #60 fixed for destructuring.

Change

Dynamic segments are kept instead of abandoning the chain. Each dynamic key is hoisted into the existing IIFE next to the root:

((_rowOrigin, _causeScopeKey) => capture("row[columnId]", _rowOrigin[_causeScopeKey], undefined, {
  originValue: _rowOrigin,
  accessPath: typeof _causeScopeKey === "number" ? "[" + _causeScopeKey + "]" : "[" + JSON.stringify(String(_causeScopeKey)) + "]"
}))(row, columnId)
  • Evaluated once. The key is an argument, and both the member read and the path consume the parameter, so a key with side effects or an unstable result cannot make the value and its path disagree. Covered by a test using row[nextKey()].
  • Source order preserved. Keys are hoisted inner-to-outer, matching the original evaluation order. Covered by matrix[r][c].
  • Static chains are untouched. The path is a concatenation only when a dynamic key forces it; a fully literal chain emits the same string literal as before. All 38 pre-existing plugin tests pass unchanged.

The emitted string is the grammar parseAccessPath already accepts, so runtime resolution needed no change.

Also fixed

Found while writing the end-to-end test, and unrelated to the feature: #registerOrigin joined every non-array key with a dot, so a key that is not a valid identifier produced a path that is not the accessor it claims to be —

response.rows.row-7.status     ← reads as a subtraction
response.rows["row-7"].status  ← what it actually is

formatAccessPath already bracketed these correctly; the two now share one identifier pattern. This affects paths shown in the inspector and in exported traces, which are meant to be copy-pasteable.

Verification

  • 5 new plugin tests, 2 new runtime tests. The runtime tests assert the resolved origin is confirmed with the full path, including one case past the 100-property registration budget.
  • The bracket fix was caught by a test failing against the old behaviour, not written to match it.
  • pnpm typecheck, pnpm test: 24/24 tasks.
  • Forced uncached rebuild, verify:performance: budgets pass (Vite plugin graph 47.7 → 50.5 KiB).
  • verify:public-api: 5 entrypoints, snapshot unchanged.
  • React 19 Playwright e2e: 12/12.

Closing note on the third item

The earlier list also flagged bounded recording (depth 5, 100 properties) as possibly indistinguishable from "no evidence exists". That turned out not to be a real problem. A probe against the runtime showed #originsAlongAccessPath falls back to the nearest registered ancestor and appends the remaining path, so a value 8 levels deep or at array index 150 still resolves to a confirmed origin with the full path. The budget limits which objects are indexed, not whether the path can be reconstructed. Nothing to fix there.

🤖 Generated with Claude Code

`staticMemberInfo` abandoned a member chain the moment it met a computed
key that was not a literal, and `wrapCapturedMember` returned false with
it. `row[columnId]`, `data[key].status`, and `items[index].name` are
ordinary table and list code, so the whole access path was dropped and a
primitive read was left with no provenance at all — the same gap the
previous commit closed for destructuring, reached a different way.

Keeps those segments instead. A dynamic key is hoisted into the existing
IIFE alongside the root, so it is evaluated exactly once and in the
original inner-to-outer order; the member read and the access path then
consume the same parameter and cannot disagree. The path is built as a
concatenation only when a key forces it, so a fully static chain emits
the identical string literal it did before.

Also fixes a reporting bug found while testing this. #registerOrigin
joined every non-array key with a dot, so a key that is not a valid
identifier produced a path that is not the accessor it claims to be:
`rows.row-7.status` reads as a subtraction. formatAccessPath already
bracketed those; both now share one identifier pattern.

Runtime resolution is unchanged — the emitted path is the grammar
parseAccessPath already accepts.

Co-Authored-By: Claude Opus 5 <[email protected]>
@stackloomdev
stackloomdev merged commit e52ce0d into main Jul 28, 2026
11 checks passed
@stackloomdev
stackloomdev deleted the feat/dynamic-key-provenance branch July 28, 2026 07:23

@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: 83257c2fd0

ℹ️ 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".

Comment on lines +629 to +632
t.callExpression(
t.memberExpression(t.identifier("JSON"), t.identifier("stringify")),
[t.callExpression(t.identifier("String"), [t.cloneNode(key)])],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact runtime keys before embedding them in origin paths

When a computed key contains secret material—for example, a traced prop or network object read as records[token]—this serializes the raw token into accessPath. The runtime then copies it into ValueOrigin.path, while both display redaction (packages/runtime-core/src/index.ts:1685-1693) and export redaction (packages/runtime-core/src/index.ts:770-775) leave that field untouched, exposing the secret in the inspector, Markdown, and exported traces even when the captured value itself is redacted. Sanitize sensitive dynamic keys before adding them to provenance paths.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

t.callExpression(t.cloneNode(captureIdentifier), captureArguments),
),
[t.cloneNode(member.root)],
[t.cloneNode(chain.root), ...keyArguments],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve computed-key evaluation order

For a newly supported optional access such as row?.[nextKey()], placing every dynamic key in the IIFE argument list invokes nextKey() before _rowOrigin?.[...]; the original expression skips that call when row is nullish. Multi-segment reads likewise evaluate outer keys before intermediate property reads, rather than interleaving key evaluation and property access, so getters or side-effecting keys can throw, mutate state, or produce a different rendered value only when instrumentation is enabled.

Useful? React with 👍 / 👎.

t.stringLiteral("["),
t.callExpression(
t.memberExpression(t.identifier("JSON"), t.identifier("stringify")),
[t.callExpression(t.identifier("String"), [t.cloneNode(key)])],

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 Keep symbol-keyed reads out of string provenance

When the dynamic key is a Symbol, JavaScript reads the symbol-keyed property, but String(key) turns it into text such as "Symbol(status)". The runtime can then append that string accessor to the registered root origin and report it as confirmed, describing a string property that was never read. Either represent symbol keys losslessly or omit their access paths as unavailable rather than manufacturing a causal chain.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Comment on lines +624 to +625
t.binaryExpression("===", t.unaryExpression("typeof", t.cloneNode(key)), t.stringLiteral("number")),
concat(concat(t.stringLiteral("["), t.cloneNode(key)), t.stringLiteral("]")),

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 Serialize non-index numeric keys in parser-compatible form

For valid object accesses such as record[-1], record[1.5], record[NaN], or record[Infinity], this number branch emits [-1], [1.5], [NaN], or [Infinity]. However, parseAccessPath in packages/runtime-core/src/index.ts:73 accepts only unsigned decimal digits in unquoted brackets, so it rejects each path and the newly supported read silently loses its confirmed origin. Encode non-index numbers as quoted property strings or extend the parser consistently.

Useful? React with 👍 / 👎.

t.stringLiteral("["),
t.callExpression(
t.memberExpression(t.identifier("JSON"), t.identifier("stringify")),
[t.callExpression(t.identifier("String"), [t.cloneNode(key)])],

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 Bound runtime keys before retaining provenance paths

A computed key can be user-controlled and arbitrarily large, but this materializes its complete string on every render and the runtime subsequently retains it in accessPath and ValueOrigin.path. Existing event and node budgets do not cap the size of an individual path, so a multi-megabyte lookup key can block rendering and inflate inspections, Markdown, and exported traces. Enforce a path-length limit and report provenance as unavailable instead of retaining an unbounded key.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

Comment on lines +630 to +631
t.memberExpression(t.identifier("JSON"), t.identifier("stringify")),
[t.callExpression(t.identifier("String"), [t.cloneNode(key)])],

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 Avoid resolving generated helpers through user bindings

The inserted JSON and String identifiers resolve in the application's lexical scope. If a component has a parameter, import, or local binding named either one, an otherwise valid row[key] read is transformed into a call to that user value's stringify or call signature, which can throw or create an incorrect path only when CauseScope instrumentation is active. Generate collision-proof helper bindings or reference the intended built-ins without using shadowable application names.

Useful? React with 👍 / 👎.

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.

1 participant