@@ -511,7 +511,60 @@ export function wrapDeclarativeHook(
511511 * `get` and `getOwnPropertyDescriptor` traps below for the instrument-by-
512512 * instrument account, and `content/docs/automation/hooks.mdx` (Hook Context)
513513 * for the author-facing statement of the same rule.
514+ *
515+ * [#12603] ⛔ SYMBOL keys are REFUSED, not routed. `set` and `defineProperty`
516+ * throw a `TypeError` for a symbol-keyed write instead of letting it reach
517+ * `data` — see those two traps below for the refusal and
518+ * `refuseSymbolPayloadKey` for the error text. See "Symbol keys" below the
519+ * reserved-name callout in `content/docs/automation/hooks.mdx`.
520+ */
521+
522+ /**
523+ * [#12603] Maintainer ruling, 2026-08-27, Option C refusal arm: a record
524+ * payload is a declarable, string-keyed field set — no metadata schema can
525+ * declare a symbol field, so a symbol key on this flat `ctx.input` face is a
526+ * JS-runtime artifact leaking toward storage, not a legal payload field.
527+ *
528+ * Thrown from the `set` and `defineProperty` traps, BEFORE either touches
529+ * `data` — so a refused write never persists and never needs hiding from
530+ * enumeration. That is the shape change from the pre-#12603 state: a
531+ * symbol-keyed write used to succeed silently, reach `data`, and persist,
532+ * while only `Object.getOwnPropertySymbols` / `Reflect.ownKeys` omitted it
533+ * (pinned open in `hook-input-ownkeys-agreement.test.ts` under #12578). Hiding
534+ * a key the engine nonetheless persisted is exactly the shape #12277, #12397
535+ * and #12578 exist to abolish; refusing the write outright is the other way
536+ * to close that gap, and the one this ruling chose (Option B — publish
537+ * symbols via `Reflect.ownKeys` — was declined for minting an undeclarable
538+ * key kind as contract).
539+ *
540+ * A plain `TypeError`, not a new subclass: this fires from INSIDE the hook
541+ * BODY (an author wrote `input[sym] = …`), the same category as the native
542+ * `TypeError` the `defineProperty` trap already lets through for a
543+ * non-configurable descriptor the target does not carry (see that trap's own
544+ * comment) — an author-code defect, not a declarative-layer diagnostic like
545+ * `HookConditionError`. It is therefore subject to the ordinary handler error
546+ * path: `onError: 'log'` can swallow it, `retryPolicy` can retry it, exactly
547+ * as any other throw from the handler body (unlike `HookConditionError`,
548+ * which is deliberately raised OUTSIDE that path — see the comment on that
549+ * class for why the two are not the same shape).
550+ *
551+ * The message names the key kind (a symbol, not "a bad key"), the surface
552+ * (hook input), and the fix (a string key, or keep the value off the payload)
553+ * — written for the accidental case the ruling calls out: an author spreading
554+ * an object that happens to carry a symbol-keyed cache entry onto `ctx.input`.
514555 */
556+ function refuseSymbolPayloadKey ( prop : symbol , trap : 'set' | 'defineProperty' ) : never {
557+ const verb = trap === 'set' ? 'Cannot set' : 'Cannot define' ;
558+ throw new TypeError (
559+ `${ verb } ${ String ( prop ) } on hook input: a symbol key is not a valid record-payload field. ` +
560+ 'A record payload is a declarable, string-keyed field set — no metadata schema can declare ' +
561+ 'a symbol field, so a symbol key here would be a JS-runtime artifact leaking toward storage. ' +
562+ 'Use a string key, or keep the value off the payload entirely (e.g. a local variable) if it ' +
563+ 'is not meant to be stored. This often happens by accident, such as spreading an object that ' +
564+ 'carries a symbol-keyed cache entry onto ctx.input.'
565+ ) ;
566+ }
567+
515568function installFlatInput ( ctx : HookContext ) : ( ) => void {
516569 const raw : any = ctx . input ?? { } ;
517570 const looksWrapped =
@@ -545,7 +598,14 @@ function installFlatInput(ctx: HookContext): () => void {
545598 }
546599 return Reflect . get ( target , prop , receiver ) ;
547600 } ,
601+ // [#12603] Symbol keys are REFUSED here, before anything else runs — a
602+ // symbol can never equal one of the four reserved (string) names, so the
603+ // check can sit first without disturbing that branch below. See
604+ // `refuseSymbolPayloadKey` for why this throws instead of routing.
548605 set ( target , prop , value ) {
606+ if ( typeof prop === 'symbol' ) {
607+ refuseSymbolPayloadKey ( prop , 'set' ) ;
608+ }
549609 if ( prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data' ) {
550610 ( target as any ) [ prop ] = value ;
551611 return true ;
@@ -600,12 +660,23 @@ function installFlatInput(ctx: HookContext): () => void {
600660 // A throw is a diagnosis; the silence was not. Omitting `configurable`
601661 // entirely (the common spelling, and every spelling `Object.assign` and
602662 // spread produce) is unaffected.
663+ //
664+ // [#12603] Symbol keys are refused here too, identically to `set` and for
665+ // the same reason — see `refuseSymbolPayloadKey`.
603666 defineProperty ( target , prop , desc ) {
667+ if ( typeof prop === 'symbol' ) {
668+ refuseSymbolPayloadKey ( prop , 'defineProperty' ) ;
669+ }
604670 if ( prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data' ) {
605671 return Reflect . defineProperty ( target , prop , desc ) ;
606672 }
607673 return Reflect . defineProperty ( ensureData ( ) , prop , desc ) ;
608674 } ,
675+ // [#12603] `deleteProperty` is deliberately NOT guarded: since `set` and
676+ // `defineProperty` now refuse every symbol-keyed write before it reaches
677+ // `data`, a symbol key can never be there to delete. `delete input[sym]`
678+ // falls through exactly as it always has for any key `data` does not
679+ // own — a harmless no-op reporting success, not a persistence lie.
609680 has ( target , prop ) {
610681 if ( prop === 'id' || prop === 'options' || prop === 'ast' || prop === 'data' ) {
611682 return prop in target ;
@@ -670,15 +741,30 @@ function installFlatInput(ctx: HookContext): () => void {
670741 // SAME value everywhere it is read. See the descriptor trap's own comment
671742 // for the full account.
672743 //
673- // SYMBOL KEYS are deliberately still absent, and this is NOT a finding
674- // that they do not belong on a payload: `Reflect.ownKeys(data)` here would
675- // additionally publish them, and whether the record payload may carry a
676- // symbol key at all is a question about the PAYLOAD contract (they already
677- // reach `data` through the `set` trap and already persist — measured), not
678- // about this trap. It is open, reported on #12578, and the day it is
679- // answered "yes" this line becomes `Reflect.ownKeys`. Until then the
680- // symbol half of the disagreement is pinned AS open in the sibling test,
681- // so an answer changes a recorded fact rather than an unnoticed one.
744+ // [#12603] SYMBOL KEYS are absent here for a settled reason now, not an
745+ // open one: the maintainer ruling (2026-08-27, Option C refusal arm)
746+ // answered the payload-contract question this trap's comment used to
747+ // leave open ("may a record payload carry a symbol key at all?") with
748+ // NO — a record payload is a declarable, string-keyed field set, and no
749+ // metadata schema can declare a symbol field. `set` and `defineProperty`
750+ // now REFUSE a symbol-keyed write before it ever reaches `data` (see
751+ // `refuseSymbolPayloadKey`), so a symbol can no longer BE an own key of
752+ // `data` for this trap to omit or report.
753+ //
754+ // This trap itself is deliberately UNCHANGED by that ruling —
755+ // `Object.getOwnPropertyNames(target.data)` stays exactly what #12578
756+ // landed. `Reflect.ownKeys(data)` (Option B) was the alternative the
757+ // ruling declined: publishing symbols through enumeration would mint an
758+ // undeclarable key kind as contract, which is the opposite of what was
759+ // ruled. With the write refused at the boundary, the two spellings would
760+ // agree anyway — `data` can never carry a symbol key for them to differ
761+ // on — so there is no remaining reason to touch this line, and #12578's
762+ // own ruling (this card must not re-litigate `ownKeys`) forbids it.
763+ //
764+ // What used to be pinned OPEN in `hook-input-ownkeys-agreement.test.ts`
765+ // (the instrument disagreement, deliberately left standing) is now
766+ // pinned as a REFUSAL in the same file: the write throws, so there is no
767+ // persisted symbol key left for the three instruments to disagree about.
682768 ownKeys ( target ) {
683769 return target . data && typeof target . data === 'object'
684770 ? Object . getOwnPropertyNames ( target . data )
0 commit comments