Skip to content

Commit 4196480

Browse files
committed
wip(spec): teach the closed projection list a banned-keys arm
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c70581b commit 4196480

3 files changed

Lines changed: 87 additions & 2 deletions

File tree

‎packages/spec/scripts/lib/refinement-projection.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ function emitDependentRequired(
164164
jsonSchema.allOf = [...allOf, { dependentRequired: emitted }];
165165
}
166166

167+
/**
168+
* `propertyNames` with a `not` over the banned names — the keyword JSON Schema
169+
* has for a rule about NAMES, so nothing is encoded and nothing approximated.
170+
*
171+
* A node that already carries `propertyNames` is conjoined through `allOf`
172+
* rather than overwritten, for the reason `emitRequiredOneOf` is: a record
173+
* emits `propertyNames: { type: 'string' }` of its own, and replacing it would
174+
* trade the rule this arm adds for the key-type rule the node already stated.
175+
* An identical rule already present is left alone rather than duplicated, so
176+
* the arm is idempotent the way `emitNonBlankString` is.
177+
*/
178+
function emitBannedKeys(jsonSchema: JsonObject, keys: readonly string[]): void {
179+
if (keys.length === 0) return;
180+
const rule = { not: { enum: [...keys] } };
181+
if (!('propertyNames' in jsonSchema)) {
182+
jsonSchema.propertyNames = rule;
183+
return;
184+
}
185+
if (JSON.stringify(jsonSchema.propertyNames) === JSON.stringify(rule)) return;
186+
const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : [];
187+
if (allOf.some((clause) => JSON.stringify(clause) === JSON.stringify({ propertyNames: rule }))) return;
188+
jsonSchema.allOf = [...allOf, { propertyNames: rule }];
189+
}
190+
167191
/** Write one declared arm's keywords onto one emitted node. */
168192
export function emitProjectableRefinement(jsonSchema: JsonObject, declared: ProjectableRefinement): void {
169193
switch (declared.pattern) {
@@ -176,6 +200,9 @@ export function emitProjectableRefinement(jsonSchema: JsonObject, declared: Proj
176200
case 'dependent-required':
177201
emitDependentRequired(jsonSchema, declared.dependencies);
178202
return;
203+
case 'banned-keys':
204+
emitBannedKeys(jsonSchema, declared.keys);
205+
return;
179206
}
180207
}
181208

‎packages/spec/src/shared/refinement-projection.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,40 @@ export type ProjectableRefinement =
105105
readonly pattern: 'dependent-required';
106106
/** Key ⇒ the keys its presence requires. Read once here, and by the predicate. */
107107
readonly dependencies: Readonly<Record<string, readonly string[]>>;
108-
};
108+
}
109+
/**
110+
* "no document may carry any of these keys" — published as `propertyNames`
111+
* with a `not` over the banned names, the spelling JSON Schema has for a rule
112+
* about NAMES rather than about values.
113+
*
114+
* Exact in the JSON domain: a JSON object's properties are exactly its own
115+
* enumerable string-keyed ones, and `propertyNames` judges exactly those
116+
* names, so "none of the banned names is an own property" and "no property
117+
* name is one of the banned names" are one sentence read from two ends. It is
118+
* PRESENCE and never value — a banned key present with a `null` value is
119+
* present on both sides, the same equality {@link requiredOneOf} rests on.
120+
*
121+
* ⛔ The predicate reads OWN properties and never `key in value`. `in` walks
122+
* the prototype chain, so a ban on a name `Object.prototype` carries —
123+
* `toString`, `constructor`, `valueOf` — would refuse every object including
124+
* `{}`, while `propertyNames` accepts it: `'toString' in JSON.parse('{}')` is
125+
* `true`. That is a disagreement about a JSON DOCUMENT, not an edge outside
126+
* the domain, and an arm that could only approximate its rule does not belong
127+
* in this list.
128+
*
129+
* ⛔ A ban over an open set of names — every key starting with `$`, say — is
130+
* NOT this arm: its keys are a finite list, and a list that merely sampled an
131+
* open set would be wider than the rule. Such a rule stays dropped and
132+
* annotated until the list learns a pattern-shaped arm of its own.
133+
*/
134+
| { readonly pattern: 'banned-keys'; readonly keys: readonly string[] };
109135

110136
/** Every arm's `pattern` tag, for a reader that needs the list itself. */
111137
export const PROJECTABLE_REFINEMENT_PATTERNS = [
112138
'required-one-of',
113139
'non-blank-string',
114140
'dependent-required',
141+
'banned-keys',
115142
] as const;
116143

117144
/**
@@ -224,3 +251,33 @@ export function dependentRequired<K extends string, D extends string>(
224251
};
225252
return declare(rule, declared);
226253
}
254+
255+
/**
256+
* "none of these keys is present", as a `.refine()` predicate that also
257+
* declares itself.
258+
*
259+
* The key list is read once into the declaration and the predicate reads it
260+
* from there, so the published `propertyNames` and the enforced rule cannot
261+
* name different keys — the same construction {@link requiredOneOf} and
262+
* {@link dependentRequired} use, and the reason this arm needs no drift pin
263+
* either.
264+
*
265+
* Spell the slot's own banned keys at the call site:
266+
*
267+
* ```ts
268+
* z.record(z.string(), z.unknown()).refine(bannedKeys(['dialect']), {
269+
* message: 'A structured filter must not carry `dialect`',
270+
* abort: true,
271+
* })
272+
* ```
273+
*/
274+
export function bannedKeys<K extends string>(
275+
keys: readonly [K, ...K[]],
276+
): (value: object) => boolean {
277+
const declared: ProjectableRefinement = { pattern: 'banned-keys', keys: Object.freeze([...keys]) };
278+
const rule = (value: object): boolean =>
279+
!(declared as { keys: readonly string[] }).keys.some((key) =>
280+
Object.prototype.hasOwnProperty.call(value, key),
281+
);
282+
return declare(rule, declared);
283+
}

‎packages/spec/src/system/tracing.zod.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { EvaluatedExpressionInputSchema } from '../shared/expression.zod';
55
import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union';
6+
import { bannedKeys } from '../shared/refinement-projection';
67

78
/**
89
* Tracing Protocol - Distributed Tracing & Observability
@@ -386,7 +387,7 @@ export const TraceSamplingConfigSchema = lazySchema(() => z.object({
386387
// carrying the published sentence for an `ast`-only envelope or a blank
387388
// bare string.
388389
z.record(z.string(), z.unknown())
389-
.refine((value) => !('dialect' in value), {
390+
.refine(bannedKeys(['dialect']), {
390391
message: STRUCTURED_FILTER_DIALECT_REFUSED,
391392
abort: true,
392393
}),

0 commit comments

Comments
 (0)