Skip to content

Commit d7a5323

Browse files
committed
feat(spec)!: publish the banned-keys rule the tracing filter arm enforces
The published `system/TraceSamplingConfig.json` accepted `{ dialect: 'cel' }` at `composite[].condition` while the runtime refused it — the card's own worked instance of a published JSON Schema WIDER than the zod it is generated from. Teach the closed projection list a fourth named pattern, `banned-keys`, and declare the tracing slot's rule through it. Two ledger rows retired. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4196480 commit d7a5323

2 files changed

Lines changed: 204 additions & 15 deletions

File tree

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 202,
6-
"droppedRefinementSites": 553,
7-
"refinementSitesThatDidProject": 367,
8-
"refinementSitesWithNoJsonFormToCompare": 3
5+
"publishedSchemasWithDroppedRefinements": 200,
6+
"droppedRefinementSites": 551,
7+
"refinementSitesThatDidProject": 369,
8+
"refinementSitesWithNoJsonFormToCompare": 9
99
},
1010
"entries": {
1111
"ai/BlueprintField": {
@@ -1044,16 +1044,6 @@
10441044
"rateLimit"
10451045
]
10461046
},
1047-
"system/TraceSamplingConfig": {
1048-
"sites": [
1049-
"composite.element.condition.options[0]"
1050-
]
1051-
},
1052-
"system/TracingConfig": {
1053-
"sites": [
1054-
"sampling.composite.element.condition.options[0]"
1055-
]
1056-
},
10571047
"ui/Action": {
10581048
"sites": [
10591049
"in",

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

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
* set and `\S` is its complement, so the pin is the whole argument; JSON
2121
* Schema specifies `pattern` as an ECMA-262 regex, which is the same engine
2222
* this assertion runs on.
23+
* - `banned-keys` — over the whole key-presence lattice, with a
24+
* present-but-`null` value, and on a name `Object.prototype` carries, which
25+
* is the one JSON shape where "own property" and `in` come apart.
2326
*
2427
* ## Why an equality pin and not a comment
2528
*
@@ -44,11 +47,13 @@ import {
4447
NON_BLANK_PATTERN,
4548
NON_BLANK_STRING,
4649
PROJECTABLE_REFINEMENT_PATTERNS,
50+
bannedKeys,
4751
dependentRequired,
4852
projectableRefinementOf,
4953
requiredOneOf,
5054
} from '../src/shared/refinement-projection';
5155
import { SSLConfigSchema } from '../src/data/driver-sql.zod';
56+
import { TraceSamplingConfigSchema } from '../src/system/tracing.zod';
5257
import {
5358
emitProjectableRefinement,
5459
projectPublishedJsonSchema,
@@ -85,6 +90,30 @@ const requiredOneOfSatisfied = (node: Record<string, unknown>, doc: Record<strin
8590
return branches.some((branch) => branch.required.every((key) => Object.prototype.hasOwnProperty.call(doc, key)));
8691
};
8792

93+
/**
94+
* The node's banned-key rule — `propertyNames.not.enum`, wherever the emitter put
95+
* it — evaluated the way a validator would, and refusing to report anything when
96+
* the node carries no such rule, so a projection that stopped emitting fails
97+
* rather than passing vacuously.
98+
*
99+
* Both placements are read because the emitter chooses between them by what the
100+
* node already carries: a record already states `propertyNames: { type:
101+
* 'string' }`, so its ban is conjoined through `allOf`; a bare object has no
102+
* `propertyNames` and takes the rule directly.
103+
*/
104+
const bannedKeysSatisfied = (node: Record<string, unknown>, doc: Record<string, unknown>): boolean => {
105+
const clauses = [node, ...((node.allOf as Record<string, unknown>[] | undefined) ?? [])];
106+
const banned = clauses
107+
.map((clause) => (clause.propertyNames as { not?: { enum?: string[] } } | undefined)?.not?.enum)
108+
.filter((list): list is string[] => Array.isArray(list));
109+
if (banned.length === 0) {
110+
throw new Error('the node carries no propertyNames.not.enum — nothing to evaluate');
111+
}
112+
return banned.every((list) =>
113+
Object.keys(doc).every((name) => !list.includes(name)),
114+
);
115+
};
116+
88117
/**
89118
* ECMA-262 WhiteSpace ∪ LineTerminator, by code point so no control byte is
90119
* ever written into this file (`scripts/check-nul-bytes.mjs` is the authority
@@ -98,7 +127,7 @@ const BLANK_CODE_POINTS = [
98127
];
99128

100129
describe('the list of projectable patterns is CLOSED', () => {
101-
it('names exactly the two arms this change landed', () => {
130+
it('names exactly the arms this list has landed, and nothing else', () => {
102131
// ⛔ Growing this is a public-contract decision: every arm narrows a
103132
// published artifact. A new arm updates this line in the same PR, which is
104133
// what makes it a reviewed diff rather than a quiet widening of the
@@ -107,6 +136,7 @@ describe('the list of projectable patterns is CLOSED', () => {
107136
'required-one-of',
108137
'non-blank-string',
109138
'dependent-required',
139+
'banned-keys',
110140
]);
111141
});
112142

@@ -462,6 +492,175 @@ describe('dependent-required: one dependency map, read twice', () => {
462492
});
463493
});
464494

495+
describe('banned-keys: one key list, read twice', () => {
496+
it('declares the keys it was given', () => {
497+
const rule = bannedKeys(['dialect']);
498+
expect(projectableRefinementOf(rule)).toEqual({ pattern: 'banned-keys', keys: ['dialect'] });
499+
});
500+
501+
it('emits `propertyNames` with a `not` over the names, when the node states none', () => {
502+
const node: Record<string, unknown> = { type: 'object' };
503+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: ['a', 'b'] });
504+
expect(node).toEqual({ type: 'object', propertyNames: { not: { enum: ['a', 'b'] } } });
505+
});
506+
507+
it('conjoins through `allOf` rather than replacing the `propertyNames` a record already states', () => {
508+
// A record emits `propertyNames: { type: 'string' }` of its own. Replacing
509+
// it would trade the key-TYPE rule the node already stated for the key-NAME
510+
// rule this arm adds, which is a narrowing paid for with a widening.
511+
const node: Record<string, unknown> = { type: 'object', propertyNames: { type: 'string' } };
512+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: ['dialect'] });
513+
expect(node.propertyNames).toEqual({ type: 'string' });
514+
expect(node.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
515+
});
516+
517+
it('⛔ never writes a TOP-LEVEL `anyOf` or disturbs the node’s own shape', () => {
518+
const node: Record<string, unknown> = { type: 'object', properties: { a: { type: 'string' } } };
519+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: ['b'] });
520+
expect(node.anyOf).toBeUndefined();
521+
expect(node.properties).toEqual({ a: { type: 'string' } });
522+
});
523+
524+
it('is idempotent — the same arm twice states one rule, not two', () => {
525+
const node: Record<string, unknown> = { type: 'object', propertyNames: { type: 'string' } };
526+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: ['dialect'] });
527+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: ['dialect'] });
528+
expect(node.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
529+
});
530+
531+
it('drops an empty key list rather than publishing a rule that bans nothing', () => {
532+
const node: Record<string, unknown> = { type: 'object' };
533+
emitProjectableRefinement(node, { pattern: 'banned-keys', keys: [] });
534+
expect(node).toEqual({ type: 'object' });
535+
});
536+
537+
it('the predicate and the keywords agree over the whole presence lattice', () => {
538+
const rule = bannedKeys(['x', 'y']);
539+
const node = publish(z.record(z.string(), z.unknown()).refine(rule));
540+
const keys = ['x', 'y', 'z'] as const;
541+
for (let mask = 0; mask < 8; mask += 1) {
542+
const doc: Record<string, unknown> = {};
543+
keys.forEach((key, i) => {
544+
if (mask & (1 << i)) doc[key] = 'v';
545+
});
546+
const asJson = JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
547+
expect(
548+
rule(asJson),
549+
`runtime vs keywords disagree for ${JSON.stringify(asJson)}`,
550+
).toBe(bannedKeysSatisfied(node, asJson));
551+
}
552+
});
553+
554+
it('a banned key present with a `null` value is PRESENT on both sides', () => {
555+
const rule = bannedKeys(['dialect']);
556+
const node = publish(z.record(z.string(), z.unknown()).refine(rule));
557+
const doc = JSON.parse('{"dialect":null}') as Record<string, unknown>;
558+
expect(rule(doc)).toBe(false);
559+
expect(bannedKeysSatisfied(node, doc)).toBe(false);
560+
});
561+
562+
it('⛔ judges OWN properties — a name `Object.prototype` carries is not "present" in an empty document', () => {
563+
// The measurement behind the predicate reading `hasOwnProperty` and never
564+
// `key in value`: `in` walks the prototype chain, so a ban spelled with it
565+
// would refuse `{}` itself while `propertyNames` accepts it. That is a
566+
// disagreement about a JSON DOCUMENT, not an edge outside the domain.
567+
const rule = bannedKeys(['toString']);
568+
const node = publish(z.record(z.string(), z.unknown()).refine(rule));
569+
const empty = JSON.parse('{}') as Record<string, unknown>;
570+
expect('toString' in empty).toBe(true);
571+
expect(rule(empty)).toBe(true);
572+
expect(bannedKeysSatisfied(node, empty)).toBe(true);
573+
});
574+
575+
it('LIT CONTROL — the same name written INTO the document is refused by both', () => {
576+
const rule = bannedKeys(['toString']);
577+
const node = publish(z.record(z.string(), z.unknown()).refine(rule));
578+
const doc = JSON.parse('{"toString":"x"}') as Record<string, unknown>;
579+
expect(rule(doc)).toBe(false);
580+
expect(bannedKeysSatisfied(node, doc)).toBe(false);
581+
});
582+
});
583+
584+
describe("the LIVE seam: the card's own worked instance stops saying yes", () => {
585+
/** The published structured-filter arm of `TraceSamplingConfig.composite[].condition`. */
586+
const structuredFilterArm = (): Record<string, unknown> => {
587+
const node = publish(TraceSamplingConfigSchema, 'input');
588+
const composite = (node.properties as Record<string, Record<string, unknown>>).composite;
589+
const item = composite.items as Record<string, Record<string, Record<string, unknown>>>;
590+
const condition = item.properties.condition as unknown as Record<string, unknown>;
591+
return (condition.anyOf as Record<string, unknown>[])[0];
592+
};
593+
594+
/** A `TraceSamplingConfig` that parses, with only `condition` varying. */
595+
const parses = (condition: unknown): boolean =>
596+
TraceSamplingConfigSchema.safeParse({
597+
type: 'composite',
598+
composite: [{ strategy: 'always_on', condition }],
599+
}).success;
600+
601+
it('states the ban, and keeps the record shape it always stated', () => {
602+
const arm = structuredFilterArm();
603+
expect(arm.type).toBe('object');
604+
expect(arm.propertyNames).toEqual({ type: 'string' });
605+
expect(arm.allOf).toEqual([{ propertyNames: { not: { enum: ['dialect'] } } }]);
606+
});
607+
608+
it("the card's own specimen — `{ dialect: 'cel' }` — is refused by BOTH sides now", () => {
609+
const doc = JSON.parse('{"dialect":"cel"}') as Record<string, unknown>;
610+
expect(parses(doc)).toBe(false);
611+
expect(bannedKeysSatisfied(structuredFilterArm(), doc)).toBe(false);
612+
});
613+
614+
it('⛔ no document this arm ACCEPTS is refused by the emitted keywords', () => {
615+
const arm = structuredFilterArm();
616+
const rule = bannedKeys(['dialect']);
617+
const corpus: Array<Record<string, unknown>> = [
618+
{},
619+
{ amount: { $gt: 1 } },
620+
{ 'account.name': { $eq: 'acme' } },
621+
{ dialect: 'cel' },
622+
{ dialect: null },
623+
{ dialect: 'cel', source: 'record.amount > 10' },
624+
];
625+
for (const doc of corpus) {
626+
const asJson = JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
627+
// Equality, not implication: this arm is exact, so a one-sided pin would
628+
// pass a projection that had stopped narrowing at all.
629+
expect(
630+
bannedKeysSatisfied(arm, asJson),
631+
`disagreement on ${JSON.stringify(asJson)}`,
632+
).toBe(rule(asJson));
633+
}
634+
});
635+
636+
it('⛔ and the EXPRESSION the runtime still accepts is still accepted by the file', () => {
637+
// The union's other arm is what carries a dialect-bearing document, so
638+
// narrowing the structured-filter arm refuses nothing the runtime accepts.
639+
const doc = { dialect: 'cel', source: 'record.amount > 10' };
640+
expect(parses(doc)).toBe(true);
641+
const node = publish(TraceSamplingConfigSchema, 'input');
642+
const composite = (node.properties as Record<string, Record<string, unknown>>).composite;
643+
const item = composite.items as Record<string, Record<string, Record<string, unknown>>>;
644+
const condition = item.properties.condition as unknown as Record<string, unknown>;
645+
const envelope = (condition.anyOf as Record<string, unknown>[])[1];
646+
const objectArm = (envelope.anyOf as Record<string, unknown>[])[1];
647+
expect(objectArm.required).toEqual(['dialect', 'source']);
648+
});
649+
650+
it('LIT CONTROL — a structured filter with no `dialect` is accepted by both', () => {
651+
const doc = JSON.parse('{"amount":{"$gt":10}}') as Record<string, unknown>;
652+
expect(parses(doc)).toBe(true);
653+
expect(bannedKeysSatisfied(structuredFilterArm(), doc)).toBe(true);
654+
});
655+
656+
it('its ledger row is gone because the site now reads `projected`, naming the arm', () => {
657+
const census = collectDroppedRefinements('system/TraceSamplingConfig', TraceSamplingConfigSchema);
658+
const site = census.projected.find((s) => s.path === 'composite.element.condition.options[0]');
659+
expect(site?.declaredPatterns).toEqual(['banned-keys']);
660+
expect(census.dropped.map((s) => s.path)).not.toContain('composite.element.condition.options[0]');
661+
});
662+
});
663+
465664
describe('the verdict is adjudicated per NODE over every check on it', () => {
466665
const nonBlank = (): z.ZodString => z.string().refine(NON_BLANK_STRING, 'non-blank');
467666

0 commit comments

Comments
 (0)