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:
- 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. - 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.
Found while implementing #12578 (the
ownKeysown-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 keysid/options/ast/dataprecedence in thegettrap — they resolve against the envelope, never the payload:The descriptor trap orders the two the other way round — it consults
datafirst, and only falls through to the wrapper branch whendatadoes not own the key: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
wrapDeclarativeHookagainst the real proxy, onorigin/mainatf93df4dbe3(an update-shaped envelope: wrapperidbound, payload also carrying anidfield):idis listed byownKeys(it is read offdata, and the wrapper-key exclusion works by readingdatarather than by subtracting those four names — correctly, since a payload field namedidis 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 throughget:Object.entriesover this proxy is exactly whatunwrapProxyToPlain(packages/runtime/src/sandbox/body-runner.ts) runs to build a hook body'sctx.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:
get) — then a genuine payload field namedidbecomes unreadable and unlistable through the flat face, andownKeyshas to stop listing it too, or the disagreement just moves to a third instrument.getcheckdatafirst for these four names) — theninput.idstops 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'sHookTargetRebindErrorreasoning).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
idat all (andoptions/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
enumerablefiltering, and its fix (landing onclaude/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/**anddocs/**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.