Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/18670-project-dependent-required.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) Nothing an author can write is removed, renamed or re-spelled: no spec key, no export and no config field changes, and the accepted set of metadata documents is byte-for-byte what it was. What changed is a machine-readable DECLARATION catching up with the runtime it always described, so there is nothing for `objectstack migrate meta` to rewrite and no stored representation to convert. -->
12 changes: 3 additions & 9 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -738,12 +738,6 @@
]
},
"data/SQLDriverConfig": {
"sites": [
"",
"sslConfig"
]
},
"data/SSLConfig": {
"sites": [
""
]
Expand Down
53 changes: 39 additions & 14 deletions packages/spec/scripts/build-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>;
jsonSchema = projectPublishedJsonSchema(value) as Record<string, unknown>;
} catch (outputError) {
if (!isKnownUnsupported(outputError)) throw outputError;
io = 'input';
try {
jsonSchema = z.toJSONSchema(value, {
target: 'draft-2020-12',
io: 'input',
override: refinementProjectionOverride,
}) as Record<string, unknown>;
jsonSchema = projectPublishedJsonSchema(value, { io: 'input' }) as Record<string, unknown>;
} catch (inputError) {
if (!isKnownUnsupported(inputError)) throw inputError;
// THIRD attempt, #16431 (a): both directions above refuse the
Expand All @@ -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;
Expand Down Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion packages/spec/scripts/dropped-refinements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
95 changes: 79 additions & 16 deletions packages/spec/scripts/lib/dropped-refinements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ import {
CUSTOM_CHECK_KIND,
checkKindOf,
customChecksOf,
projectPublishedJsonSchema,
projectableRefinementsOf,
refinementProjectionOverride,
zodDefOf,
} from './refinement-projection';

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -221,34 +233,83 @@ 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.
}
}
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 };
}

/**
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading