From e1a306afe59d4acde8e9da6c2860bf0073f5555c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:17:49 +0000 Subject: [PATCH 1/3] feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 2 of the refinement-projection card, the third of the ruling's four named arms. The card relation is stated once, in the PR body. Clause-②: yes (narrowing) Director ruling batch 154 item 3, letter C: the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern, one ledger row at a time. ## The arm `data/SSLConfig`'s `hasCert === hasKey` is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the published file now states it. 2 sites close: `data/SSLConfig` at the export node and `data/SQLDriverConfig` at `sslConfig`. Exact, not approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. `SQLDriverConfig`'s own refinement ("sslConfig is required when ssl is TRUE") judges a VALUE, is `if`/`then` rather than this arm, and keeps its ledger row. ## Two mechanism fixes that become load-bearing with a third arm 1. The detector's verdict was per NODE while the rules are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule was recorded nowhere. `projected` now requires every `custom` check on the node to be declared; anything else is dropped conservatively. The raw differential is kept as `projectionMoved` so the detector still MEASURES rather than asserts, and the generator prints the partially-stated sites on their own line. 2. Generator and detector each passed `override:` for themselves, so their agreement was a convention: dropped on the generator side alone it left every site reading `projected` behind a green ledger while the published file went wide in silence. Both now reach `z.toJSONSchema` through `projectPublishedJsonSchema`, where there is no argument left to forget. Ledger: 201 -> 200 entries, 553 -> 551 sites; 1 row deleted, 1 row shrunk, 0 sites added anywhere. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 6 -- packages/spec/scripts/build-schemas.ts | 53 +++++++--- .../spec/scripts/dropped-refinements.test.ts | 10 +- .../spec/scripts/lib/dropped-refinements.ts | 95 ++++++++++++++--- .../spec/scripts/lib/refinement-projection.ts | 100 +++++++++++++++++- .../scripts/lib/union-branch-projection.ts | 40 +++---- .../scripts/union-branch-projection.test.ts | 32 +++--- packages/spec/src/data/driver-sql.zod.ts | 8 +- .../spec/src/shared/refinement-projection.ts | 71 ++++++++++++- 9 files changed, 327 insertions(+), 88 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 6622c962124..23cbb4a8f20 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -738,12 +738,6 @@ ] }, "data/SQLDriverConfig": { - "sites": [ - "", - "sslConfig" - ] - }, - "data/SSLConfig": { "sites": [ "" ] diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 135a8b8e1ab..b77ecae86b0 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -49,7 +49,7 @@ import { // The ratchet below measures against this same override, so a rule the list // emits leaves the ledger and a rule it does not emit stays in it — see the // module header for why the two halves must not be read against each other. -import { refinementProjectionOverride } from './lib/refinement-projection'; +import { projectPublishedJsonSchema } from './lib/refinement-projection'; // The dropped-refinement ratchet (#18670). The mirror image of the branch // pruning above, and deliberately its own module for the same reason: the // pruner guards a projection NARROWER than the Zod type, this one the direction @@ -498,19 +498,12 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { let io: 'output' | 'input' = 'output'; let prunedBranches: readonly PrunedBranch[] = []; try { - jsonSchema = z.toJSONSchema(value, { - target: 'draft-2020-12', - override: refinementProjectionOverride, - }) as Record; + jsonSchema = projectPublishedJsonSchema(value) as Record; } catch (outputError) { if (!isKnownUnsupported(outputError)) throw outputError; io = 'input'; try { - jsonSchema = z.toJSONSchema(value, { - target: 'draft-2020-12', - io: 'input', - override: refinementProjectionOverride, - }) as Record; + jsonSchema = projectPublishedJsonSchema(value, { io: 'input' }) as Record; } catch (inputError) { if (!isKnownUnsupported(inputError)) throw inputError; // THIRD attempt, #16431 (a): both directions above refuse the @@ -526,10 +519,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { // then re-thrown with the message Zod produced, so this attempt // can never change WHY an export is skipped, and so never the // `cause` recorded for it in unemitted-schemas.baseline.json. - const projected = projectByPruningUnionBranches(value, { - target: 'draft-2020-12', - override: refinementProjectionOverride, - }); + const projected = projectByPruningUnionBranches(value); if (!projected) throw inputError; jsonSchema = projected.schema; io = projected.io; @@ -3603,6 +3593,41 @@ if (projectedSiteTotal > 0) { } } +// Nodes whose projection MOVED and which are still counted as dropped — the +// reading the per-node differential cannot express as a verdict (#18670 third +// arm). Two shapes reach this line and both are news: +// +// - a node carrying a DECLARED arm beside a rule the closed list does not +// cover, so part of it is stated in the file and part of it is not. It is +// ledgered and annotated conservatively, which is what the ruling's 「A +// refinement that is not one of these named patterns stays dropped and +// annotated」 requires — before the verdict was per-check-aware such a node +// read `projected` outright and its undeclared rule was recorded nowhere; +// - zod having started to project a `custom` check on its own, which is the +// upgrade this whole instrument is waiting for and must not swallow. +// +// Printed rather than fatal: the site is already held by the ledger as a drop, +// so a NEW one fails the ratchet above on its own. What this line adds is WHICH +// of the declared population is only half-stated, which no count can say. +const partiallyStated = refinementCensus.flatMap((entry) => + entry.dropped + .filter((site) => site.projectionMoved) + .map((site) => ({ defKey: entry.defKey, site })), +); +if (partiallyStated.length > 0) { + console.log( + `\n🪢 ${partiallyStated.length} refinement site(s) are PARTIALLY stated by the published file — ` + + `the projection moved, yet not every \`custom\` check on the node is one the closed list declares, ` + + `so the node stays dropped and annotated (#18670).`, + ); + for (const { defKey, site } of partiallyStated) { + const declared = site.declaredPatterns.length > 0 + ? site.declaredPatterns.join('+') + : 'nothing declared — zod projected this on its own'; + console.log(` ${defKey} at "${site.path}": ${site.count} check(s), declared: ${declared}`); + } +} + // ─── Generate Bundled Schema ───────────────────────────────────────── // Single-file bundled schema containing all generated schemas for IDE autocomplete diff --git a/packages/spec/scripts/dropped-refinements.test.ts b/packages/spec/scripts/dropped-refinements.test.ts index 5a413f27a04..4bc134db96e 100644 --- a/packages/spec/scripts/dropped-refinements.test.ts +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -225,7 +225,15 @@ describe('the differential isolates the refinement, not the node', () => { }); describe('the ratchet adjudicates against the ledger', () => { - const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const, declaredPatterns: [] }); + const site = (path: string) => ({ + path, + nodeType: 'string', + count: 1, + aborting: false, + verdict: 'dropped' as const, + declaredPatterns: [], + projectionMoved: false, + }); const census = (defKey: string, paths: string[]) => ({ defKey, dropped: paths.map(site), diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts index 8586ed4d970..54ed82dbe4c 100644 --- a/packages/spec/scripts/lib/dropped-refinements.ts +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -90,8 +90,8 @@ import { CUSTOM_CHECK_KIND, checkKindOf, customChecksOf, + projectPublishedJsonSchema, projectableRefinementsOf, - refinementProjectionOverride, zodDefOf, } from './refinement-projection'; @@ -149,6 +149,18 @@ export interface RefinementSite { * named, which reads differently from a refinement somebody deleted. */ readonly declaredPatterns: readonly string[]; + /** + * The RAW differential this node's verdict was adjudicated from: true when + * removing its `custom` checks changes the projection at all, i.e. SOMETHING + * about them reached the published file. + * + * ⛔ Not a synonym for `verdict === 'projected'`. The pair + * `verdict: 'dropped'` + `projectionMoved: true` is the one that carries + * information neither field holds alone — some of this node's rules are + * stated and at least one is not — and the pair + * `verdict: 'projected'` + `projectionMoved: false` cannot occur. + */ + readonly projectionMoved: boolean; } /** Every refinement site under one published schema. */ @@ -221,20 +233,25 @@ function withoutCustomChecks(schema: z.ZodType): z.ZodType | null { * `toJSONSchema` in the generator's own io ladder, or `null` when neither side * has a JSON form. * - * ⭐ It passes the generator's `override` (#18670 item 2). Without it this - * function would measure a projection nothing publishes: a node whose rule the - * closed list DOES emit would read byte-identical on both sides of the - * differential and stay in the ledger for ever, and the shrink-only ledger's - * whole use — a row deletion is the observable proof a site closed — would be - * unreachable. With it, `dropped` means "this build's own published file states - * nothing about this rule". + * ⭐ It projects through `projectPublishedJsonSchema` — the SAME call the + * generator reaches `z.toJSONSchema` through (#18670). Without the refinement + * projection this function would measure a projection nothing publishes: a node + * whose rule the closed list DOES emit would read byte-identical on both sides + * of the differential and stay in the ledger for ever, and the shrink-only + * ledger's whole use — a row deletion is the observable proof a site closed — + * would be unreachable. With it, `dropped` means "this build's own published + * file states nothing about this rule". + * + * ⛔ And it is reached through the shared helper rather than by passing + * `override:` here, because the two halves agreeing was otherwise a convention: + * dropped on the generator side alone it left every declared site reading + * `projected` behind a green ledger while the published file went wide in + * silence. The helper's own docblock carries that measurement. */ function projectOrNull(schema: z.ZodType): string | null { for (const io of ['output', 'input'] as const) { try { - return JSON.stringify( - z.toJSONSchema(schema, { target: 'draft-2020-12', io, override: refinementProjectionOverride }), - ); + return JSON.stringify(projectPublishedJsonSchema(schema, { io })); } catch { // Try the other direction — the generator does the same, for the same reason. } @@ -242,13 +259,57 @@ function projectOrNull(schema: z.ZodType): string | null { return null; } -function verdictFor(schema: z.ZodType): RefinementSite['verdict'] { +/** One node's raw differential and the verdict adjudicated from it. */ +interface NodeProjectionReading { + readonly verdict: RefinementSite['verdict']; + readonly projectionMoved: boolean; +} + +/** + * Measure one node, then adjudicate it. + * + * ## The differential is per NODE, and so is the ledger — but the RULES are not + * + * `withoutCustomChecks` removes ALL of a node's custom checks at once, so the + * comparison answers "did ANY of them reach the file", never "did each". A node + * carrying one DECLARED arm and one undeclared rule therefore moved the + * differential on the strength of the declared arm alone, and reading that as + * `projected` published the undeclared rule's silence: not in the ledger, not + * in `x-dropped-refinements`, and invisible to the generator's UNDECLARED line, + * which only sees sites with zero declared patterns. The ratchet stayed green + * over a refinement the file says nothing about — exactly what the ruling's + * 「A refinement that is not one of these named patterns stays dropped and + * annotated」 forbids. Measured before this fix, on the two-arm shape + * `z.string().refine(NON_BLANK_STRING).refine((s) => s.startsWith('x'))`: + * `dropped: []`, `projected: [{ count: 2, declaredPatterns: ['non-blank-string'] }]`. + * + * So `projected` now requires that EVERY custom check on the node is one the + * closed list declared. Anything else is `dropped`, conservatively: the ledger + * unit is the node, a node cannot be half-recorded, and over-recording costs a + * row while under-recording costs the silence this whole instrument exists to + * end. + * + * ## Why the raw differential is still reported + * + * Collapsing "the projection did not move" and "it moved for reasons this list + * does not cover" into one `dropped` would make the detector assert the drop + * instead of measuring it — the failure this module's header names, and the one + * that would keep it reading as current through the zod upgrade that fixes the + * gap. `projectionMoved` is the measurement; `verdict` is the adjudication. A + * site with `verdict: 'dropped'` and `projectionMoved: true` is news either way + * — a mixed node, or zod having started to project something on its own — and + * the generator prints it on its own line. + */ +function readProjection(schema: z.ZodType): NodeProjectionReading { const stripped = withoutCustomChecks(schema); - if (!stripped) return 'undecidable'; + if (!stripped) return { verdict: 'undecidable', projectionMoved: false }; const before = projectOrNull(schema); const after = projectOrNull(stripped); - if (before === null || after === null) return 'undecidable'; - return before === after ? 'dropped' : 'projected'; + if (before === null || after === null) return { verdict: 'undecidable', projectionMoved: false }; + if (before === after) return { verdict: 'dropped', projectionMoved: false }; + const stated = projectableRefinementsOf(schema).length; + const total = customChecksOf(schema).length; + return { verdict: total === stated ? 'projected' : 'dropped', projectionMoved: true }; } /** @@ -396,13 +457,15 @@ export function collectDroppedRefinements(defKey: string, root: z.ZodType): Refi const customs = customChecksOf(schema); if (customs.length > 0) { + const reading = readProjection(schema); const site: RefinementSite = { path: readablePath(path), nodeType: String(zodDefOf(schema)?.type ?? 'unknown'), count: customs.length, aborting: customs.some(checkAborts), - verdict: verdictFor(schema), + verdict: reading.verdict, declaredPatterns: projectableRefinementsOf(schema).map((declared) => declared.pattern), + projectionMoved: reading.projectionMoved, }; if (site.verdict === 'dropped') dropped.push(site); else if (site.verdict === 'projected') projected.push(site); diff --git a/packages/spec/scripts/lib/refinement-projection.ts b/packages/spec/scripts/lib/refinement-projection.ts index da567e13bd4..35d5161a426 100644 --- a/packages/spec/scripts/lib/refinement-projection.ts +++ b/packages/spec/scripts/lib/refinement-projection.ts @@ -33,7 +33,7 @@ * the runtime accepts becomes refused. ⛔ An arm that could only approximate * its rule would be a behaviour change wearing a correction's clothes. */ -import type { z } from 'zod'; +import { z } from 'zod'; import { NON_BLANK_PATTERN, projectableRefinementOf, @@ -137,6 +137,33 @@ function emitNonBlankString(jsonSchema: JsonObject): void { jsonSchema.allOf = [...allOf, { pattern: NON_BLANK_PATTERN }]; } +/** + * JSON Schema's own `dependentRequired` — the keyword whose meaning IS this + * arm's sentence, so nothing is encoded and nothing approximated. + * + * An entry with an empty requirement list is dropped rather than emitted: it + * constrains nothing, and `{}` in the published file would read as a rule to + * anyone diffing it. A node that somehow already carries the keyword is + * conjoined through `allOf` rather than overwritten, for the reason + * `emitRequiredOneOf` is: two arms on one node must both land. + */ +function emitDependentRequired( + jsonSchema: JsonObject, + dependencies: Readonly>, +): void { + const emitted: Record = {}; + for (const [key, required] of Object.entries(dependencies)) { + if (required.length > 0) emitted[key] = [...required]; + } + if (Object.keys(emitted).length === 0) return; + if (!('dependentRequired' in jsonSchema)) { + jsonSchema.dependentRequired = emitted; + return; + } + const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : []; + jsonSchema.allOf = [...allOf, { dependentRequired: emitted }]; +} + /** Write one declared arm's keywords onto one emitted node. */ export function emitProjectableRefinement(jsonSchema: JsonObject, declared: ProjectableRefinement): void { switch (declared.pattern) { @@ -146,6 +173,9 @@ export function emitProjectableRefinement(jsonSchema: JsonObject, declared: Proj case 'non-blank-string': emitNonBlankString(jsonSchema); return; + case 'dependent-required': + emitDependentRequired(jsonSchema, declared.dependencies); + return; } } @@ -176,3 +206,71 @@ export function composeOverrides(first: (ctx: C) => void, second: (ctx: C) => second(ctx); }; } + +/** + * The `target` every published projection uses. Named once because it is now + * passed from one place; a second literal elsewhere would be a second answer to + * a question that has one. + */ +export const PUBLISHED_JSON_SCHEMA_TARGET = 'draft-2020-12' as const; + +/** + * The context object `z.toJSONSchema` hands its `override`. `jsonSchema` is the + * node's emitted object, which every override here writes keywords onto, so it + * is typed as one rather than as `unknown`. + */ +export interface ProjectionOverrideContext { + readonly zodSchema: unknown; + readonly jsonSchema: JsonObject; + readonly path: (string | number)[]; +} + +/** + * ⭐ The ONE call through which `z.toJSONSchema` is reached anywhere the + * published projection is produced — the generator's three attempts, the + * union-branch projector behind the third, and the detector's differential in + * `dropped-refinements.ts`. + * + * ## Why a choke point and not a convention + * + * The generator and the detector have to project the SAME way or the ledger + * stops describing the file. While each passed `override:` for itself, that + * agreement was a convention two call sites kept, and the failure mode was + * silent and one-sided: drop it on the GENERATOR side alone and every declared + * site still reads `projected` — the detector is still passing it — so the + * ledger stays green, the gate stays green, and the published file goes WIDE + * again with no `x-dropped-refinements` to say so. Measured on the code before + * this helper existed: with the generator's import stubbed out, `gen:schema` + * exited 0 and printed the same 553 dropped / 201 schemas / 197 projected as an + * untouched run, while `shared/Expression.json` lost its `allOf` and the + * non-blank pattern went from 35 published files to 0. That is the pre-#18729 + * silence restored, standing behind a green ratchet — strictly worse than the + * state the card was filed about, because the ledger now certifies it. + * + * A merge-conflict resolution was enough to cause it; nothing had to be + * misunderstood. So the override is applied HERE, where the caller has no + * argument to drop, and both halves lose it together or not at all — which is + * what makes the ablation that removes it loud rather than silent. + * + * A caller that needs an override of its own (the union-branch projector marks + * nodes with one) hands it in `override` and it runs FIRST, before the + * refinement pass — the order those two passes were written for. + */ +export function projectPublishedJsonSchema( + schema: z.ZodType, + options: { + readonly io?: 'input' | 'output'; + readonly unrepresentable?: 'any' | 'throw'; + readonly override?: (ctx: ProjectionOverrideContext) => void; + } = {}, +): unknown { + const { io, unrepresentable, override } = options; + return z.toJSONSchema(schema, { + target: PUBLISHED_JSON_SCHEMA_TARGET, + ...(io === 'input' ? { io } : {}), + ...(unrepresentable ? { unrepresentable } : {}), + override: override + ? composeOverrides(override, refinementProjectionOverride) + : refinementProjectionOverride, + }); +} diff --git a/packages/spec/scripts/lib/union-branch-projection.ts b/packages/spec/scripts/lib/union-branch-projection.ts index 571dadf88f9..a69df5783c1 100644 --- a/packages/spec/scripts/lib/union-branch-projection.ts +++ b/packages/spec/scripts/lib/union-branch-projection.ts @@ -61,6 +61,7 @@ * type Zod refused. */ import { z } from 'zod'; +import { projectPublishedJsonSchema } from './refinement-projection'; /** * Temporary marker key written onto a node Zod could not project. It never @@ -267,39 +268,24 @@ export function findSurvivingMark(node: unknown, at = '#'): string | null { * Fewest drops is the most faithful projection available, and the `x-io` flag * already tells a reader which shape they are looking at (#2967 / #2978). */ -export function projectByPruningUnionBranches( - value: z.ZodType, - options: { - readonly target: 'draft-2020-12'; - /** - * An extra `override` to run after this module's own marker pass — the - * generator's refinement projection (#18670 item 2). This function owns the - * single `override` slot `toJSONSchema` provides, so a caller that also - * needs one hands it here rather than losing one of the two silently: an - * export that reaches its published file through THIS path would otherwise - * be the one artifact missing a narrowing the ledger already recorded as - * closed (`data/Hook` is the live case). - */ - readonly override?: (ctx: { zodSchema: unknown; jsonSchema: unknown; path: (string | number)[] }) => void; - }, -): BranchProjection | null { +export function projectByPruningUnionBranches(value: z.ZodType): BranchProjection | null { const candidates: BranchProjection[] = []; for (const io of ['output', 'input'] as const) { let schema: JsonObject; try { - const mark = markUnprojectableNodes(io); - const extra = options.override; - schema = z.toJSONSchema(value, { - target: options.target, + // ⭐ Through `projectPublishedJsonSchema`, never `z.toJSONSchema` directly + // (#18670 third arm). This path owns the single `override` slot + // `toJSONSchema` provides, so the refinement projection used to be handed + // in by the caller — and an export reaching its published file through + // HERE was then one forgotten argument away from being the one artifact + // missing a narrowing the ledger records as closed (`data/Hook` is the + // live case). The helper composes this module's marker pass with the + // refinement pass itself, so there is no argument left to forget. + schema = projectPublishedJsonSchema(value, { + io, unrepresentable: 'any', - override: extra - ? (ctx): void => { - mark(ctx); - extra(ctx); - } - : mark, - ...(io === 'input' ? { io } : {}), + override: markUnprojectableNodes(io), }) as JsonObject; } catch { // `unrepresentable: 'any'` removes the unrepresentable-type throws, so diff --git a/packages/spec/scripts/union-branch-projection.test.ts b/packages/spec/scripts/union-branch-projection.test.ts index 644dd9000f9..3d9070f5185 100644 --- a/packages/spec/scripts/union-branch-projection.test.ts +++ b/packages/spec/scripts/union-branch-projection.test.ts @@ -33,6 +33,7 @@ import { pruneMarkedUnionBranches, type PrunedBranch, } from './lib/union-branch-projection'; +import { PUBLISHED_JSON_SCHEMA_TARGET } from './lib/refinement-projection'; import { ComparisonOperatorSchema, FieldOperatorsSchema, @@ -42,7 +43,6 @@ import { } from '../src/data'; import { FlowFunctionEntrySchema } from '../src/automation'; -const TARGET = { target: 'draft-2020-12' } as const; /** Convert with the marker override, the way the projection itself does. */ function markedProjection(schema: z.ZodType, io: 'output' | 'input' = 'output'): Record { @@ -68,6 +68,9 @@ function collect(node: unknown, key: string, into: unknown[] = []): unknown[] { return into; } +/** The target the two DIRECT `z.toJSONSchema` controls below convert at. */ +const TARGET = { target: PUBLISHED_JSON_SCHEMA_TARGET } as const; + describe('markUnprojectableNodes — what counts as "no JSON form"', () => { it('marks a bare z.date(), in BOTH io directions', () => { // The premise #16431 recorded — that the existing `io: 'input'` fallback @@ -137,7 +140,6 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies it('projects the ordering comparand union without its Date branch', () => { const projected = projectByPruningUnionBranches( z.object({ $gt: z.union([z.number(), z.date(), z.string()]).optional() }), - TARGET, ); expect(projected).not.toBeNull(); expect(projected!.pruned).toEqual([{ at: '#/properties/$gt/anyOf/1', type: 'date' }]); @@ -150,13 +152,13 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies it('refuses when an unprojectable node is NOT a union member', () => { // Dropping a required `handler` would publish a shape no runtime value has. - expect(projectByPruningUnionBranches(z.object({ handler: z.function() }), TARGET)).toBeNull(); - expect(projectByPruningUnionBranches(z.record(z.string(), z.function()), TARGET)).toBeNull(); - expect(projectByPruningUnionBranches(z.array(z.date()), TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(z.object({ handler: z.function() }))).toBeNull(); + expect(projectByPruningUnionBranches(z.record(z.string(), z.function()))).toBeNull(); + expect(projectByPruningUnionBranches(z.array(z.date()))).toBeNull(); }); it('refuses a union whose every branch is unprojectable', () => { - expect(projectByPruningUnionBranches(z.union([z.date(), z.function()]), TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(z.union([z.date(), z.function()]))).toBeNull(); }); it('refuses a marked node NESTED inside a SURVIVING union branch', () => { @@ -189,7 +191,7 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies expect(findSurvivingMark(marked)).toBe('#/anyOf/0/properties/handler'); } - expect(projectByPruningUnionBranches(nested, TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(nested)).toBeNull(); }); it('leaves Automation.FlowFunctionEntrySchema skipped, marker and all', () => { @@ -215,11 +217,11 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies expect(JSON.stringify(marked)).toContain(UNPROJECTABLE_MARK); } - expect(projectByPruningUnionBranches(FlowFunctionEntrySchema as z.ZodType, TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(FlowFunctionEntrySchema as z.ZodType)).toBeNull(); }); it('returns null when there was nothing to drop', () => { - expect(projectByPruningUnionBranches(z.object({ a: z.string() }), TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(z.object({ a: z.string() }))).toBeNull(); }); it('prefers the direction that drops FEWER branches, not output-first', () => { @@ -231,7 +233,7 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies z.string().transform((s) => s.length), z.number(), ]); - const projected = projectByPruningUnionBranches(withTransform, TARGET); + const projected = projectByPruningUnionBranches(withTransform); expect(projected).not.toBeNull(); expect(projected!.io).toBe('input'); expect(projected!.pruned.map((b) => b.type)).toEqual(['date']); @@ -239,7 +241,7 @@ describe('projectByPruningUnionBranches — the contract build-schemas.ts relies }); it('never returns a schema still carrying a marker, and never an empty `{}` branch', () => { - const projected = projectByPruningUnionBranches(ComparisonOperatorSchema, TARGET); + const projected = projectByPruningUnionBranches(ComparisonOperatorSchema); expect(projected).not.toBeNull(); expect(findSurvivingMark(projected!.schema)).toBeNull(); expect(JSON.stringify(projected!.schema)).not.toContain(UNPROJECTABLE_MARK); @@ -266,15 +268,15 @@ describe('the four filter exports #16431 measured, and the boundary beside them' /cannot be represented in JSON Schema/, ); } - const projected = projectByPruningUnionBranches(schema as z.ZodType, TARGET); + const projected = projectByPruningUnionBranches(schema as z.ZodType); expect(projected).not.toBeNull(); expect(projected!.pruned).toHaveLength(dropped); expect(new Set(projected!.pruned.map((b) => b.type))).toEqual(new Set(['date'])); }); it('publishes the five operators the card named, with their prose intact', () => { - const comparison = projectByPruningUnionBranches(ComparisonOperatorSchema, TARGET)!; - const range = projectByPruningUnionBranches(RangeOperatorSchema, TARGET)!; + const comparison = projectByPruningUnionBranches(ComparisonOperatorSchema)!; + const range = projectByPruningUnionBranches(RangeOperatorSchema)!; const slots = comparison.schema.properties as Record; for (const op of ['$gt', '$gte', '$lt', '$lte']) { expect(slots[op]?.description).toContain('null is NOT a comparand'); @@ -286,6 +288,6 @@ describe('the four filter exports #16431 measured, and the boundary beside them' it('leaves a driver interface of z.function() members skipped', () => { // The population the #16431 ratchet holds closed must not be emptied by a // projection that publishes shapes nobody authors. - expect(projectByPruningUnionBranches(PersistenceAdapterSchema, TARGET)).toBeNull(); + expect(projectByPruningUnionBranches(PersistenceAdapterSchema)).toBeNull(); }); }); diff --git a/packages/spec/src/data/driver-sql.zod.ts b/packages/spec/src/data/driver-sql.zod.ts index e35d89f4319..5f174e7db5d 100644 --- a/packages/spec/src/data/driver-sql.zod.ts +++ b/packages/spec/src/data/driver-sql.zod.ts @@ -8,6 +8,7 @@ import { DriverConfigSchema } from './driver.zod'; * Supported SQL database dialects */ import { lazySchema } from '../shared/lazy-schema'; +import { dependentRequired } from '../shared/refinement-projection'; export const SQLDialectSchema = lazySchema(() => z.enum([ 'postgresql', 'mysql', @@ -65,12 +66,7 @@ export const SSLConfigSchema = lazySchema(() => z.object({ ca: z.string().optional().describe('CA certificate file path or content'), cert: z.string().optional().describe('Client certificate file path or content'), key: z.string().optional().describe('Client private key file path or content'), -}).refine((data) => { - // If cert is provided, key must also be provided, and vice versa - const hasCert = data.cert !== undefined; - const hasKey = data.key !== undefined; - return hasCert === hasKey; -}, { +}).refine(dependentRequired({ cert: ['key'], key: ['cert'] }), { message: 'Client certificate (cert) and private key (key) must be provided together', })); diff --git a/packages/spec/src/shared/refinement-projection.ts b/packages/spec/src/shared/refinement-projection.ts index 73ffdead837..003c265e0b5 100644 --- a/packages/spec/src/shared/refinement-projection.ts +++ b/packages/spec/src/shared/refinement-projection.ts @@ -83,10 +83,36 @@ export type ProjectableRefinement = * is redundant beside the pattern and is emitted anyway, because it is the * keyword a form generator and a reference table read. */ - | { readonly pattern: 'non-blank-string' }; + | { readonly pattern: 'non-blank-string' } + /** + * "whenever this key is present, those keys must be present too" — published + * as JSON Schema's own `dependentRequired`, which is that sentence and + * nothing else. + * + * Exact in the JSON domain, by the same equality {@link requiredOneOf} rests + * on read from the other end: a key absent from a JSON object is the only way + * for its value to read `undefined`, so "present" and "not undefined" name + * one fact. `dependentRequired` triggers on PRESENCE, so a key present with + * any JSON value — `null` included — arms its dependency exactly as the + * predicate's `!== undefined` does. + * + * ⛔ It is presence, never VALUE. A rule of the shape "`sslConfig` is + * required when `ssl` is **true**" is `if`/`then`, is not this arm, and stays + * dropped and annotated — `data/SQLDriverConfig`'s own refinement is that + * shape and keeps its ledger row. + */ + | { + readonly pattern: 'dependent-required'; + /** Key ⇒ the keys its presence requires. Read once here, and by the predicate. */ + readonly dependencies: Readonly>; + }; /** Every arm's `pattern` tag, for a reader that needs the list itself. */ -export const PROJECTABLE_REFINEMENT_PATTERNS = ['required-one-of', 'non-blank-string'] as const; +export const PROJECTABLE_REFINEMENT_PATTERNS = [ + 'required-one-of', + 'non-blank-string', + 'dependent-required', +] as const; /** * The ECMA-262 pattern accepting exactly the strings {@link NON_BLANK_STRING} @@ -157,3 +183,44 @@ export const NON_BLANK_STRING: (source: string) => boolean = declare( (source: string): boolean => source.trim().length > 0, { pattern: 'non-blank-string' }, ); + +/** + * "whenever a key is present, the keys it depends on are present too", as a + * `.refine()` predicate that also declares itself. + * + * The dependency map is read once into the declaration and the predicate reads + * it from there, so the published `dependentRequired` and the enforced rule + * cannot name different keys — the same construction {@link requiredOneOf} + * uses, and the reason neither arm needs a drift pin. + * + * A MUTUAL requirement ("both or neither") is spelled as the two one-way + * entries it is, which is also exactly how `dependentRequired` spells it: + * + * ```ts + * z.object({ cert: …, key: … }).refine(dependentRequired({ cert: ['key'], key: ['cert'] }), { + * message: 'Client certificate (cert) and private key (key) must be provided together', + * }) + * ``` + */ +export function dependentRequired( + dependencies: Readonly>, +): (value: Readonly>>) => boolean { + const declared: ProjectableRefinement = { + pattern: 'dependent-required', + dependencies: Object.freeze( + Object.fromEntries( + Object.entries(dependencies as Readonly>).map( + ([key, required]) => [key, Object.freeze([...required])] as const, + ), + ), + ), + }; + const rule = (value: Readonly>>): boolean => { + const record = value as Record; + return Object.entries((declared as { dependencies: Readonly> }).dependencies) + .every(([key, required]) => + record[key] === undefined || required.every((dependency) => record[dependency] !== undefined), + ); + }; + return declare(rule, declared); +} From 50adbb3e0f517ae5a0f17b893ad83ee5c6a8bae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:23:14 +0000 Subject: [PATCH 2/3] test(spec): pin the dependentRequired arm and both projection mechanism fixes Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 6 +- .../scripts/refinement-projection.test.ts | 237 +++++++++++++++++- 2 files changed, 232 insertions(+), 11 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 23cbb4a8f20..1caca158f86 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,9 +2,9 @@ "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.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 201, - "droppedRefinementSites": 553, - "refinementSitesThatDidProject": 197, + "publishedSchemasWithDroppedRefinements": 200, + "droppedRefinementSites": 551, + "refinementSitesThatDidProject": 199, "refinementSitesWithNoJsonFormToCompare": 3 }, "entries": { diff --git a/packages/spec/scripts/refinement-projection.test.ts b/packages/spec/scripts/refinement-projection.test.ts index 0f892bdb064..4cc1399ff98 100644 --- a/packages/spec/scripts/refinement-projection.test.ts +++ b/packages/spec/scripts/refinement-projection.test.ts @@ -44,13 +44,15 @@ import { NON_BLANK_PATTERN, NON_BLANK_STRING, PROJECTABLE_REFINEMENT_PATTERNS, + dependentRequired, projectableRefinementOf, requiredOneOf, } from '../src/shared/refinement-projection'; +import { SSLConfigSchema } from '../src/data/driver-sql.zod'; import { emitProjectableRefinement, + projectPublishedJsonSchema, projectableRefinementsOf, - refinementProjectionOverride, } from './lib/refinement-projection'; import { collectDroppedRefinements } from './lib/dropped-refinements'; import { @@ -58,13 +60,16 @@ import { ExpressionSchema, } from '../src/shared/expression.zod'; -/** Exactly the call shape `build-schemas.ts` publishes with. */ +/** + * Exactly the call `build-schemas.ts` publishes with — the shared helper + * itself, not a re-spelling of it. ⛔ Deliberately NOT a local + * `z.toJSONSchema(..., { override })`: that is the convention this change + * replaced, and a test that kept it would go on passing through the one edit + * that matters (the override dropped from the helper) while the published file + * went wide. + */ const publish = (schema: z.ZodType, io: 'input' | 'output' = 'output'): Record => - z.toJSONSchema(schema, { - target: 'draft-2020-12', - io, - override: refinementProjectionOverride, - }) as Record; + projectPublishedJsonSchema(schema, { io }) as Record; /** * The node's `allOf[].anyOf[].required` rule — evaluated the way a validator @@ -98,7 +103,11 @@ describe('the list of projectable patterns is CLOSED', () => { // published artifact. A new arm updates this line in the same PR, which is // what makes it a reviewed diff rather than a quiet widening of the // narrowing. - expect([...PROJECTABLE_REFINEMENT_PATTERNS]).toEqual(['required-one-of', 'non-blank-string']); + expect([...PROJECTABLE_REFINEMENT_PATTERNS]).toEqual([ + 'required-one-of', + 'non-blank-string', + 'dependent-required', + ]); }); it('a refinement nobody declared gets NO keyword', () => { @@ -335,3 +344,215 @@ describe('the ledger measures THIS projection', () => { expect(census.projected[0].declaredPatterns).toEqual(['non-blank-string']); }); }); + +describe('dependent-required: one dependency map, read twice', () => { + /** + * The node's `dependentRequired`, evaluated the way a validator would, and + * refusing to report anything when the node carries no such keyword — so a + * projection that stopped emitting fails rather than passing vacuously. + */ + const dependentRequiredSatisfied = ( + node: Record, + doc: Record, + ): boolean => { + const map = node.dependentRequired as Record | undefined; + if (!map || Object.keys(map).length === 0) { + throw new Error('the node carries no `dependentRequired` — nothing to evaluate'); + } + const present = (key: string): boolean => Object.prototype.hasOwnProperty.call(doc, key); + return Object.entries(map).every(([key, required]) => !present(key) || required.every(present)); + }; + + it('declares the dependency map it was given', () => { + const rule = dependentRequired({ cert: ['key'], key: ['cert'] }); + expect(projectableRefinementOf(rule)).toEqual({ + pattern: 'dependent-required', + dependencies: { cert: ['key'], key: ['cert'] }, + }); + }); + + it('emits JSON Schema`s own `dependentRequired`, and nothing else', () => { + const node: Record = { type: 'object' }; + emitProjectableRefinement(node, { + pattern: 'dependent-required', + dependencies: { a: ['b'] }, + }); + expect(node).toEqual({ type: 'object', dependentRequired: { a: ['b'] } }); + }); + + it('⛔ never writes a TOP-LEVEL `anyOf` or replaces the node`s own shape', () => { + // Same absence the required-one-of arm pins: `format-type.ts` reads `anyOf` + // before `properties`, so a top-level one costs the reference table the + // object shape it used to state. + const node: Record = { type: 'object', properties: { a: { type: 'string' } } }; + emitProjectableRefinement(node, { pattern: 'dependent-required', dependencies: { a: ['b'] } }); + expect(node.anyOf).toBeUndefined(); + expect(node.properties).toEqual({ a: { type: 'string' } }); + }); + + it('drops an entry that requires nothing rather than publishing an empty rule', () => { + const node: Record = { type: 'object' }; + emitProjectableRefinement(node, { pattern: 'dependent-required', dependencies: { a: [] } }); + expect(node).toEqual({ type: 'object' }); + }); + + it('conjoins through `allOf` rather than replacing a keyword the node already has', () => { + const node: Record = { type: 'object', dependentRequired: { a: ['b'] } }; + emitProjectableRefinement(node, { pattern: 'dependent-required', dependencies: { c: ['d'] } }); + expect(node.dependentRequired).toEqual({ a: ['b'] }); + expect(node.allOf).toEqual([{ dependentRequired: { c: ['d'] } }]); + }); + + it('the predicate and the keyword agree over the whole presence lattice', () => { + const rule = dependentRequired({ cert: ['key'], key: ['cert'] }); + const node = publish( + z.object({ + ca: z.string().optional(), + cert: z.string().optional(), + key: z.string().optional(), + }).refine(rule), + ); + const keys = ['ca', 'cert', 'key'] as const; + for (let mask = 0; mask < 1 << keys.length; mask += 1) { + const doc: Record = {}; + keys.forEach((key, i) => { + if (mask & (1 << i)) doc[key] = '/path'; + }); + // The JSON round-trip is what makes "absent" and "undefined" one fact — + // the equality this arm rests on, exactly as required-one-of does. + const asJson = JSON.parse(JSON.stringify(doc)) as Record; + expect( + rule(asJson as never), + `runtime vs keywords disagree for ${JSON.stringify(asJson)}`, + ).toBe(dependentRequiredSatisfied(node, asJson)); + } + }); + + it('a key present with a `null` value ARMS its dependency on both sides', () => { + const rule = dependentRequired({ cert: ['key'], key: ['cert'] }); + const node = publish( + z.object({ cert: z.unknown().optional(), key: z.unknown().optional() }).refine(rule), + ); + const doc = { cert: null }; + expect(rule(doc as never)).toBe(false); + expect(dependentRequiredSatisfied(node, doc)).toBe(false); + }); + + it('LIVE SEAM — `data/SSLConfig` states the rule, and both sides agree on a corpus', () => { + const node = publish(SSLConfigSchema); + expect(node.dependentRequired).toEqual({ cert: ['key'], key: ['cert'] }); + const corpus: Array> = [ + {}, + { ca: '/ca.pem' }, + { cert: '/c.pem' }, + { key: '/k.pem' }, + { cert: '/c.pem', key: '/k.pem' }, + { ca: '/ca.pem', cert: '/c.pem', key: '/k.pem' }, + { ca: '/ca.pem', cert: '/c.pem' }, + { rejectUnauthorized: false, key: '/k.pem' }, + ]; + for (const doc of corpus) { + // Equality, not implication: the arm is exact, so a one-sided pin would + // pass a projection that had stopped narrowing at all. + expect( + dependentRequiredSatisfied(node, doc), + `disagreement on ${JSON.stringify(doc)}`, + ).toBe(SSLConfigSchema.safeParse(doc).success); + } + }); +}); + +describe('the verdict is adjudicated per NODE over every check on it', () => { + const nonBlank = (): z.ZodString => z.string().refine(NON_BLANK_STRING, 'non-blank'); + + it('a DECLARED arm beside an UNDECLARED rule stays `dropped`, with the arm still named', () => { + // Before this was fixed the whole node read `projected` on the strength of + // the declared arm, so the undeclared rule reached neither the ledger nor + // `x-dropped-refinements` nor the generator's UNDECLARED line — a silent + // violation of 「A refinement that is not one of these named patterns stays + // dropped and annotated」. + const mixed = nonBlank().refine((s) => s.startsWith('x'), 'must start with x'); + const census = collectDroppedRefinements('test/Mixed', mixed); + expect(census.projected).toEqual([]); + expect(census.dropped).toHaveLength(1); + expect(census.dropped[0].count).toBe(2); + expect(census.dropped[0].declaredPatterns).toEqual(['non-blank-string']); + // The RAW differential is kept, so the detector still measures rather than + // asserts: something about this node DID reach the file. + expect(census.dropped[0].projectionMoved).toBe(true); + }); + + it('LIT CONTROL — the declared arm ALONE on the same shape reads `projected`', () => { + const census = collectDroppedRefinements('test/DeclaredOnly', nonBlank()); + expect(census.dropped).toEqual([]); + expect(census.projected).toHaveLength(1); + expect(census.projected[0].count).toBe(1); + expect(census.projected[0].projectionMoved).toBe(true); + }); + + it('LIT CONTROL — the undeclared rule ALONE reads `dropped` and moved NOTHING', () => { + const census = collectDroppedRefinements( + 'test/UndeclaredOnly', + z.string().refine((s) => s.startsWith('x'), 'must start with x'), + ); + expect(census.projected).toEqual([]); + expect(census.dropped).toHaveLength(1); + expect(census.dropped[0].declaredPatterns).toEqual([]); + expect(census.dropped[0].projectionMoved).toBe(false); + }); + + it('two DECLARED arms on one node read `projected` — the fix is not "more than one check"', () => { + const both = z.object({ a: z.string().optional(), b: z.string().optional() }) + .refine(requiredOneOf(['a', 'b']), 'one of a or b') + .refine(dependentRequired({ a: ['b'] }), 'a needs b'); + const census = collectDroppedRefinements('test/TwoArms', both); + expect(census.dropped).toEqual([]); + expect(census.projected).toHaveLength(1); + expect(census.projected[0].count).toBe(2); + expect(census.projected[0].declaredPatterns).toEqual(['required-one-of', 'dependent-required']); + }); + + it('⛔ `projected` with a differential that never moved cannot occur', () => { + for (const schema of [nonBlank(), z.string().refine((s) => s.length > 2)]) { + for (const site of collectDroppedRefinements('test/Invariant', schema).projected) { + expect(site.projectionMoved).toBe(true); + } + } + }); +}); + +describe('generator and detector project through ONE call, not two conventions', () => { + it('the helper applies the refinement projection with NO override from the caller', () => { + // The coupling, asserted where it now lives. A caller passing nothing is + // the generator's own call shape; if the override were still the caller's + // to remember, this would come back byte-identical to a bare projection. + const declared = z.string().refine(NON_BLANK_STRING, 'non-blank'); + expect(projectPublishedJsonSchema(declared)).toMatchObject({ + minLength: 1, + pattern: NON_BLANK_PATTERN, + }); + }); + + it('a caller`s OWN override runs first, and does not displace the refinement pass', () => { + const declared = z.string().refine(NON_BLANK_STRING, 'non-blank'); + const marked = projectPublishedJsonSchema(declared, { + override: (ctx) => { + (ctx.jsonSchema as Record)['x-marked'] = true; + }, + }) as Record; + expect(marked['x-marked']).toBe(true); + expect(marked.pattern).toBe(NON_BLANK_PATTERN); + }); + + it('the detector`s differential reads the SAME projection the helper publishes', () => { + // Both halves through one call: a site the helper emits for is `projected` + // here, and the ledger's "a row deletion is the proof a site closed" holds + // only while that is true. + const schema = z.object({ cert: z.string().optional(), key: z.string().optional() }) + .refine(dependentRequired({ cert: ['key'], key: ['cert'] }), 'together'); + expect(publish(schema).dependentRequired).toEqual({ cert: ['key'], key: ['cert'] }); + const census = collectDroppedRefinements('test/Coupled', schema); + expect(census.dropped).toEqual([]); + expect(census.projected.map((s) => s.declaredPatterns)).toEqual([['dependent-required']]); + }); +}); From cb2d486ca4812a2b5be9660ded1594bb7520036c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:32:15 +0000 Subject: [PATCH 3/3] chore(spec): changeset for the dependentRequired arm Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude --- .../18670-project-dependent-required.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/18670-project-dependent-required.md diff --git a/.changeset/18670-project-dependent-required.md b/.changeset/18670-project-dependent-required.md new file mode 100644 index 00000000000..3097a25e477 --- /dev/null +++ b/.changeset/18670-project-dependent-required.md @@ -0,0 +1,28 @@ +--- +"@objectstack/spec": minor +--- + +**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the cert/key pairing rule on SSL driver configuration, so a validator reading the published files stops answering PASS on a half-configured client certificate the platform then refuses (#18670 item 2, the third of the ruling's four named arms). + +Clause-②: yes (narrowing) + +One named pattern joins the closed list, and only one: + +- **`dependentRequired` — "whenever this key is present, those keys must be present too"**, emitted as JSON Schema's own `dependentRequired`. `SSLConfig`'s rule that a client certificate and its private key are provided together is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the file now states it. + +**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 201 entries / 553 sites to **200 entries / 551 sites**: + +| row | before | after | +|:---|:---|:---| +| `data/SSLConfig` | `sites: [""]` | **deleted** — the schema drops nothing now | +| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed | + +2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. `data/SQLDriverConfig`'s remaining `""` site is its own separate rule — "`sslConfig` is required when `ssl` is **true**" — which judges a VALUE rather than key presence, is `if`/`then` rather than this arm, and stays dropped and annotated as `x-dropped-refinements`. + +**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. Measured over a 10,368-document corpus across both affected schemas: the runtime verdict vector is byte-identical before and after (lit control — weakening the dependency map to one direction moves 96 documents), and of the 36 documents the published files stop accepting, **zero** are documents the runtime accepts. Across the whole published tree, 1530 of 1532 files are byte-identical; the two that move gain `dependentRequired` and lose the matching `x-dropped-refinements` row. + +**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the dependency map is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. A refinement outside the list stays unprojected and keeps its annotation. `propertyNames` / `not` for banned keys remains untaken: the tree carries no candidate whose rule is mechanically derivable, so no arm was constructed for it. + +**Two mechanism repairs ship with it**, both invisible in the published output and both load-bearing from this arm onward. The detector's verdict was reached per NODE while refinements are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule reached neither the ledger nor the annotation; `projected` now requires every check on the node to be declared, and the generator reports partially-stated sites on their own line. And the generator and the detector each passed the projection `override` for themselves — dropping it on the generator side alone left every site reading `projected` behind a green ledger while the published file silently went wide — so both now reach `z.toJSONSchema` through one shared call with no argument left to forget. + +