Skip to content

[finding] A record payload field named id/options/ast/data reads back the WRAPPER's value through the flat-input proxy while its descriptor reports the PAYLOAD's — get and getOwnPropertyDescriptor disagree on the same key #12601

Description

@os-warren

Found while implementing #12578 (the ownKeys own-key set). Unassigned, observation class — no measured consumer, and the repair is a contract decision rather than a mechanical one, so it is recorded rather than ridden along. Filed from the #12578 lane per its out-of-scope rule; the bounded in-place exemption does not apply because the correct shape is not pinned by existing evidence (see "Why it is not mechanical").

What it does

installFlatInput (packages/objectql/src/hook-wrappers.ts) gives the four wrapper keys id / options / ast / dataprecedence in the get trap — they resolve against the envelope, never the payload:

get(target,prop,receiver){if(prop==='id'||prop==='options'||prop==='ast'||prop==='data'){returnReflect.get(target,prop,receiver);// <- the WRAPPER}}

The descriptor trap orders the two the other way round — it consults datafirst, and only falls through to the wrapper branch when data does not own the key:

getOwnPropertyDescriptor(target,prop){constdata=target.data;if(data&&typeofdata==='object'){constown=Object.getOwnPropertyDescriptor(data,prop);if(own)return{ ...own,configurable: true};// <- the PAYLOAD}}

So for a record whose payload genuinely carries a field called id, the two traps answer about the same key from two different objects.

Measured

Driven through wrapDeclarativeHook against the real proxy, on origin/main at f93df4dbe3 (an update-shaped envelope: wrapper id bound, payload also carrying an id field):

constraw={data: {id: 'PAYLOAD-ID',subject: 'help'},options: {},id: 'WRAPPER-ID'};input.id// 'WRAPPER-ID' <- get trap, wrapperObject.getOwnPropertyDescriptor(input,'id').value// 'PAYLOAD-ID' <- descriptor trap, payloadReflect.ownKeys(input).includes('id')// true <- listed, from `data`

id is listed by ownKeys (it is read off data, and the wrapper-key exclusion works by reading data rather than by subtracting those four names — correctly, since a payload field named id is a genuine record field). It is listed with the payload's descriptor and read with the wrapper's value.

The consequence worth naming is that the split reaches ordinary copying, because a spread reads keys from ownKeys, filters them through the descriptor trap, and then takes values through get:

{ ...input}.id// 'WRAPPER-ID' — the copy carries the envelope's id under a payload field's nameObject.entries(input)// same: ['id', 'WRAPPER-ID']

Object.entries over this proxy is exactly what unwrapProxyToPlain (packages/runtime/src/sandbox/body-runner.ts) runs to build a hook body's ctx.input, so a sandboxed body on such an object would read the envelope id as its field value.

Why it is not mechanical

Which side should win is a contract question, not a spelling choice, and the two candidate answers differ in what they assert about the payload:

  1. Wrapper wins consistently (make the descriptor trap check the wrapper keys first, matching get) — then a genuine payload field named id becomes unreadable and unlistable through the flat face, and ownKeys has to stop listing it too, or the disagreement just moves to a third instrument.
  2. Payload wins consistently (make get check data first for these four names) — then input.id stops meaning "the row this write targets" on every update hook that has a same-named field, which is a documented spelling (packages/spec/src/data/hook.zod.ts's envelope table, and D4's HookTargetRebindError reasoning).

Option 2 in particular collides with an existing ruling, so this is not a case where one answer is obviously the truthful one. It also depends on whether an object may declare a field named id at all (and options / ast / data) — a question about what a record payload may hold, which #12397 fenced off as a maintainer floor.

Scope note

Distinct from #12578, which is the same trap set but a different mechanism: that card is enumeration-vs-descriptor disagreement caused by enumerable filtering, and its fix (landing on claude/issue-12578-ownkeys-own-key-set) does not touch wrapper-key precedence in either direction. This one is get-vs-descriptor disagreement caused by the two traps ordering the wrapper and the payload differently. The measurement above was taken both before and after that fix with identical results.

Dedup

Local grep across packages/** and docs/** for the wrapper-key collision — no in-tree record. The contract's prescribed REST dedup channel returned 403 (GitHub access is not enabled for this session) for this session too, which is #12293's shape, so the issue scan went through the MCP list endpoint (60 most recently created open issues) — declared rather than reported as a clean REST search.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions