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
26 changes: 26 additions & 0 deletions .changeset/18670-project-banned-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
---

**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the banned-key rule the tracing sampling filter enforces, so a validator reading the published files stops answering PASS on `{ "dialect": "cel" }` at `TraceSamplingConfig.composite[].condition` — the card's own worked instance of a published file saying yes to metadata the runtime refuses (#18670 item 2, the fourth of the ruling's named arms).

Clause-②: yes (narrowing)

One named pattern joins the closed list, and only one:

- **`banned-keys` — "no document may carry any of these keys"**, emitted as `propertyNames` with a `not` over the banned names. `TraceSamplingConfig.composite[].condition` is a structured filter of match criteria that refuses an object carrying `dialect`, because such an object is an expression attempt and this slot's expression arm was retired in 17.5.0. The published file now says so.

**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 202 entries / 553 sites to **200 entries / 551 sites**:

| row | before | after |
|:---|:---|:---|
| `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — the schema drops nothing now |
| `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent |

2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **357 projected** — 224 `non-blank-string`, 129 `required-one-of`, 2 `dependent-required`, **2 `banned-keys`** — 9 undecidable.

**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value — a banned key present with a `null` value is present on both sides. The accept set at the slot is **unchanged in both directions**: every document the runtime takes (`{}`, `{ "service": "api" }`, any filter carrying no `dialect` key) the file still takes, and every document the runtime refuses the file now refuses too — a `dialect`-bearing object of any shape, the CEL envelope included, since that arm is retired and nothing here revives it. Across the published tree, **1528 of the 1530 per-schema files are byte-identical**; the two that move gain the ban and lose the matching `x-dropped-refinements` row, and nothing else in either file changes.

**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the key list is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. The predicate judges OWN properties and never `key in value`: `in` walks the prototype chain, so a ban on a name `Object.prototype` carries would refuse `{}` itself while `propertyNames` accepts it, and that is a disagreement about a JSON document rather than an edge outside the domain. A ban over an OPEN set of names — every key starting with `$`, which is what `data/filter.zod.ts`'s normalized field condition refuses — is deliberately not this arm: its keys are a finite list, and a list that merely sampled an open set would be wider than the rule, so those sites stay unprojected — and because the detector reads them `undecidable` rather than `dropped`, they carry NO annotation and hold NO ledger row: published yet unratcheted.

<!-- 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. -->
18 changes: 4 additions & 14 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"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": 202,
"droppedRefinementSites": 553,
"refinementSitesThatDidProject": 367,
"refinementSitesWithNoJsonFormToCompare": 3
"publishedSchemasWithDroppedRefinements": 200,
"droppedRefinementSites": 551,
"refinementSitesThatDidProject": 357,
"refinementSitesWithNoJsonFormToCompare": 9
},
"entries": {
"ai/BlueprintField": {
Expand Down Expand Up @@ -1044,16 +1044,6 @@
"rateLimit"
]
},
"system/TraceSamplingConfig": {
"sites": [
"composite.element.condition"
]
},
"system/TracingConfig": {
"sites": [
"sampling.composite.element.condition"
]
},
"ui/Action": {
"sites": [
"in",
Expand Down
33 changes: 33 additions & 0 deletions packages/spec/scripts/lib/refinement-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,36 @@ function emitDependentRequired(
jsonSchema.allOf = [...allOf, { dependentRequired: emitted }];
}

/**
* `propertyNames` with a `not` over the banned names — the keyword JSON Schema
* has for a rule about NAMES, so nothing is encoded and nothing approximated.
*
* A node that already carries `propertyNames` is conjoined through `allOf`
* rather than overwritten, for the reason `emitRequiredOneOf` is: a record
* emits `propertyNames: { type: 'string' }` of its own, and replacing it would
* trade the rule this arm adds for the key-type rule the node already stated.
* An identical rule already present is left alone rather than duplicated, so
* the arm is idempotent the way `emitNonBlankString` is.
*
* An EMPTY key list emits nothing, and the reason is stronger than "it would
* ban nothing": `enum` is specified as a non-empty array, so `{ not: { enum:
* [] } }` is an INVALID SCHEMA rather than a vacuous one — ajv refuses it with
* "enum must have non-empty array", which would take the whole published file
* down with it instead of leaving a keyword nobody reads.
*/
function emitBannedKeys(jsonSchema: JsonObject, keys: readonly string[]): void {
if (keys.length === 0) return;
const rule = { not: { enum: [...keys] } };
if (!('propertyNames' in jsonSchema)) {
jsonSchema.propertyNames = rule;
return;
}
if (JSON.stringify(jsonSchema.propertyNames) === JSON.stringify(rule)) return;
const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : [];
if (allOf.some((clause) => JSON.stringify(clause) === JSON.stringify({ propertyNames: rule }))) return;
jsonSchema.allOf = [...allOf, { propertyNames: rule }];
}

/** Write one declared arm's keywords onto one emitted node. */
export function emitProjectableRefinement(jsonSchema: JsonObject, declared: ProjectableRefinement): void {
switch (declared.pattern) {
Expand All @@ -176,6 +206,9 @@ export function emitProjectableRefinement(jsonSchema: JsonObject, declared: Proj
case 'dependent-required':
emitDependentRequired(jsonSchema, declared.dependencies);
return;
case 'banned-keys':
emitBannedKeys(jsonSchema, declared.keys);
return;
}
}

Expand Down
Loading
Loading