Skip to content

Commit 5eebc9e

Browse files
os-steveclaude
andauthored
feat(spec)!: publish the banned-keys rule the tracing filter arm enforces (#19137)
Part of #18670 — item 2, the **fourth** of the ruling's four named arms: **banned keys**. This body carries no closing keyword for that number on purpose: measured banned-key sites are still unprojected (§6), and whether the card closes is the seat's call rather than this PR's. Clause-②: yes **Carrier:** the published artefacts `packages/spec/json-schema/system/TraceSamplingConfig.json` and `system/TracingConfig.json`. The published JSON Schema **narrows** toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ **The `yes` stands on the ruling's own axis** — a published artefact narrows — and the at-tier review measured that it stands there **independently of the C5 tell**: `check:api-surface` and `check:api-surface-declarations` both exit 0 with **no diff at all**, because `src/shared/refinement-projection.ts` is re-exported by no entry barrel and is not a `.zod.ts`, so it is not in `files[]`. The C5 widening tell is real — the as-const roster `PROJECTABLE_REFINEMENT_PATTERNS` gains `banned-keys` and an exported `bannedKeys()` appears beside it — but that roster is an **internal** `export const`, not the package's public entry surface. ⛔ The `yes` does not depend on it either way. Director ruling batch #154 item 3, letter **C** (maintainer 「同意」, 2026-09-18T04:56Z): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as `x-dropped-refinements`」. --- ## ⛔ This body was REPLACED WHOLESALE by the seat, and last refreshed at 2026-09-19T00:07Z for head `184615ded9` The delivering dev writes a PR body once, at creation, and ⛔ does not patch it; a later correction is named in its report for the seat to write. That convention met a case it does not cover: **the tree the first body described no longer exists.** PR #19084 (`ee5812a5e3`) retired the CEL expression arm at this very slot before this branch merged `origin/main`, so `condition` is now a plain record and not a union — and the union framing ran through §0, §1, §3 and §4 alike. A patch of some sections would have left the artefact self-contradictory about the only tree it can land on, so the seat replaced it rather than appending a third correction block. Five things were stale, and each is now stated for head `384d27ac18`: | # | was | now | |:---|:---|:---| | 1 | the slot framed as a UNION, the ban emitted into `anyOf[0]` | a RECORD; the ban is conjoined onto it directly (§1, §3) | | 2 | 「#19005 的普查走到 X 就停了」 — an account of a sibling release being wrong | **RETRACTED.** The candidate set is TIME-DEPENDENT; #19005 read its own tree correctly (§0) | | 3 | the `$`-ban reaches ONE published node | **THREE**, each measured and named (§6) | | 4 | `77 derived / 74 exit 0 / 3 exit 3` | **82 derived / 78 run, all exit 0 / 4 NOT MEASURED** (§7) | | 5 | a live `Clause-②` disagreement between the claim and the ruling | settled at **`yes`** on both carriers, and the claim comment carries the correction | ⛔ Item 4 and item 5 were the **seat's** errors, not the dev's: the dev copied the claim line verbatim as the dual carrier requires, and only the seat writes claims and labels. Item 2 was the dev's, and the dev retracted it itself on measurement. The retracted text is preserved at the end of this body as HISTORY rather than deleted. --- ## 0. The pre-condition the releasing seat set — and the answer The release of #19005 set a hard gate on whoever took this card next: > Whoever takes it next must **re-derive the banned-keys candidate set FIRST** and, if it is still empty, **return the card rather than dispatching a dev to find nothing.** **Re-derived. The set is NOT empty, and its clean member is the card's own worked instance.** ⭐ **The candidate set is TIME-DEPENDENT, and that is the whole reason the pre-condition was worth setting.** #19005's census recorded zero clean candidates, and that was a **correct reading of its own tree** — the `dialect` predicate at this slot did not exist yet; it arrived with #18638, hours later. The instruction to re-derive the set FIRST is exactly what caught a candidate that landed after the last census, and it is the reason this card had work in it at all. ⛔ No sibling release was wrong; an earlier draft of this body said one was, and that claim is withdrawn. **Instrument:** a TypeScript-AST scan of every `.refine` / `.superRefine` / `.check` call expression under `packages/spec/src/**/*.ts` (non-test), dumping each predicate's argument text — **114 custom-check call sites** across 1008 source files (`superRefine` 69, `refine` 44, `check` 1; 3 `.overwrite` calls excluded, they are not custom checks). LIT CONTROL: 6 of those call sites spell an already-declared arm (`requiredOneOf` ×2, `NON_BLANK_STRING` ×3, `dependentRequired` ×1), so the scan does see the population it is supposed to see. **Radius, by form:** source text of tracked files. **A known target outside it:** whether a given call site's node is a *ledger row* — the ledger's sites are computed at run time by the detector against `packages/spec/json-schema/**`, which is gitignored and returns 0 tracked entries. That is precisely why the earlier shape-only reading on this card was recorded as "not a reading". So the population question was answered with the instrument that can see it: `collectDroppedRefinements` run over the live schemas, plus the generator's own census. **Result — 4 of the 114 predicates judge KEYS at all**, and they split three ways: | call site | predicate | verdict | |:---|:---|:---| | `src/system/tracing.zod.ts` (sampling `condition`) | `!('dialect' in value)` | ⭐ **clean candidate** — a static, self-contained, finite key ban. **2 ledger rows.** | | `src/data/filter.zod.ts:1916` | `!Object.keys(condition).some((key) => key.startsWith('$'))` | an **open** key set — not this arm (§6). Detector verdict `undecidable`, **0 ledger rows**, yet **3 published nodes**. | | `src/ui/action.zod.ts:1844` | `Object.keys(hints).every((k) => known.has(k))` | allowed keys computed from the sibling `data.params` — not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes. | | `src/data/driver/common.zod.ts:537` | credential leaks at named paths | judges **values**, not key names. Not this pattern. | ## 1. The arm `banned-keys` — "no document may carry any of these keys" — emitted as `propertyNames` with a `not` over the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced: `src/shared/refinement-projection.ts` declares the arm and builds the predicate from that declaration, `scripts/lib/refinement-projection.ts` emits it, and both halves still reach `z.toJSONSchema` through the one shared `projectPublishedJsonSchema` call. **The slot is a record, not a union.** #19084 retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition`, so the node is now a single `z.record(z.string(), z.unknown())` carrying the retirement's own refusal hook and its `abort: true` message. The anonymous `.refine((value) => !('dialect' in value))` that guarded it is replaced by the **declared** `bannedKeys(['dialect'])` — the retirement's prescription, error hook and message are taken from `main` whole, and only the predicate is declared. ⛔ The retirement's behaviour is unchanged by this PR; what changes is that the rule now has a published form. **Exact, not 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 predicate reads OWN properties and never `key in value`.** `in` walks the prototype chain, so a ban on a name `Object.prototype` carries — `toString`, `constructor`, `valueOf` — would refuse `{}` itself while `propertyNames` accepts it (`'toString' in JSON.parse('{}')` is `true`). That is a disagreement about a JSON **document**, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spells `Object.prototype.hasOwnProperty.call(value, key)` for that reason. **The emitted keywords are conjoined, never substituted.** The node is a record and already states `propertyNames: { type: 'string' }` of its own; replacing it would trade a key-TYPE rule for a key-NAME rule, which is a narrowing paid for with a widening. The ban goes under `allOf`, the same discipline `emitNonBlankString` follows for an existing `pattern`, and the measured `format-type.ts` hazard is untouched — a top-level `anyOf` is still never written, and the reference renderer reads neither `allOf` nor `propertyNames`. **An empty key list emits nothing**, and for a stronger reason 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 instead of leaving a keyword nobody reads. The declaring signature takes a non-empty tuple, so the guard is belt-and-braces at a seam two files apart. ## 2. The rows retired, by name `packages/spec/dropped-refinements.baseline.json`, **202 entries / 553 sites → 200 / 551**: | row | before | after | |:---|:---|:---| | `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — drops nothing now | | `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent | ⚠️ Both paths are the **post-retirement** spellings. On the tree this PR was first written against they read `…condition.options[0]`, because the node was then a union arm; #19084 renamed them by making the node a record, and the rows deleted here are the renamed ones. 2 rows deleted, 0 shrunk, **2 sites closed, 0 sites added anywhere**; 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. The `measured` block is re-snapshotted from this run: `refinementSitesThatDidProject` 367 → **357** and `refinementSitesWithNoJsonFormToCompare` 3 → **9**. ⛔ **This PR moved neither number.** The projected total fell because #19084 retired expression arms elsewhere in the tree; the main-tip block was already stale on its own tree. Re-snapshotting is what this PR owes for editing the file at all, and it is not a reading this arm produced. ## 3. The card's own worked instance, before and after The issue body cites `system/TraceSamplingConfig.json`: ``` condition.anyOf[0] = {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}} ``` — "That accepts `{dialect:'cel'}` — which the **runtime refuses**." The union wrapper is gone with #19084; the same record is now the node itself, and on the merge base it publishes unchanged in substance: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } ``` After: ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {}, "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ] } ``` and `x-dropped-refinements` is gone from both artefacts. Measured at the slot: `{ "dialect": "cel" }` is refused by the runtime and now by the file; `{ "dialect": "cel", "source": "record.amount > 10" }` is refused by **both** sides — ⚠️ that is **#19084's retirement**, not this PR, and this PR neither revives the expression arm nor extends the refusal; `{ "amount": { "$gt": 10 } }` is accepted by both; `{}` and `{ "service": "api" }` are accepted by both; `{ "dialect": null }` is refused by both. ## 4. Blast radius, measured on the whole published tree Re-measured on the **new** base (`aadea24b89`): the three edited source files were reverted to `origin/main`, the generator re-run, and the two trees compared byte for byte. | reading | value | |:---|:---| | per-schema files common to both trees | 1530 | | **byte-identical** | **1528** | | moved | **2** — `system/TraceSamplingConfig.json`, `system/TracingConfig.json` | The diff of each moved file is exactly: **gain** the `allOf` ban, **lose** the matching `x-dropped-refinements` row. Nothing else in either file changes. (The revert leg was proven on disk — each path's blob hash equalled its `origin/main` blob — and the restore leg by `git diff HEAD` printing nothing.) `openapi.json` was measured **separately and by the right instrument this time**: `gen:schema` never writes it, so the first comparison read two missing files and reported a false MOVED. Running `gen:openapi` on both trees gives a byte-identical file, sha256 `34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on both sides. ## 5. Ablation — the pins can fail, both halves Re-run on the **new** head; the earlier ablation measured a tree that no longer exists. `scripts/ablation-replace.mjs` replaced the one line dispatching the arm (`emitBannedKeys(jsonSchema, declared.keys);`) in `scripts/lib/refinement-projection.ts`, with the mutation verified against the disk (anchor 1 → 0, blob `0a21fb6f9b66` → `6e55fe06cef5`): | leg | result | |:---|:---| | `refinement-projection.test.ts` | **exit 1** — 12 failed / 46 passed, including the live seam and the ledger-verdict pin | | `gen:schema` | **exit 1** — naming **both renamed rows** (`composite.element.condition`, `sampling.composite.element.condition`), each record/aborting | | restore | blob back to HEAD, `git diff HEAD` empty | The second leg is the one that matters for the ledger's whole purpose: with the emitter gone, the two deleted rows come **back** as undeclared gaps. The row deletion is load-bearing, not decorative. ## 6. What is left, measured rather than estimated `src/data/filter.zod.ts:1916` bans **every key starting with `$`** on a normalized field condition, and it reaches **THREE** published record nodes in `packages/spec/json-schema/data/NormalizedFilter.json`: - `properties.$and.items.anyOf[0]` - `properties.$or.items.anyOf[0]` - `properties.$not.anyOf[0]` Measured on this head: **all three publish as a bare object** with `propertyNames: { type: 'string' }` and **no ban**, none of them appears in that file's `x-dropped-refinements`, and the file **PASSes a document the runtime refuses** — the runtime's answer for that document names the rule: 「a field condition's keys are field names, never `$`-prefixed operators」. All three read **`undecidable`** to the detector, because `FieldOperatorsSchema` carries `z.date()` members that throw in both io directions — so they hold **0 ledger rows** while the branch-pruning path publishes them anyway. ⭐ **Published yet undecidable is a ratchet blind spot in its own right**, and it deserves a line of its own on the card's worklist, separate from the fifth arm it would take to close. Closing the rule itself is a second public-contract decision, not a refactor of this one: an open key set cannot be spelled as a finite `keys:` list — a list that merely sampled the open set would be WIDER than the rule, which the closed list forbids by construction. It needs a pattern-shaped declaration (`propertyNames: { not: { pattern: "^\\$" } }`). ⇒ closing it is a real narrowing with **no ledger row to make it testable**, which is the opposite trade from this arm. ⭐ The changeset now says the same thing. An earlier revision of it claimed these sites 「stay unprojected and **keep their annotation**」, which is false on the tree; the at-tier review caught the disagreement between the two carriers and the clause was corrected before landing. `src/ui/action.zod.ts:1844` stays dropped and annotated, correctly: its allowed key set is computed from the sibling `data.params`, and JSON Schema cannot express "property names drawn from another array field's values". ## 7. Verification Run on head **`184615ded9`**, each exit code captured **before** any pipe. ⭐ **The at-tier contract review returned PASS**, on head `384d27ac18` (record: PR comment `5737573936`). The branch has moved once since, by exactly one prose clause in one changeset file (`git diff --stat 384d27a 184615d` → `1 file changed, 1 insertion(+), 1 deletion(-)`), so the contract surface the review judged is byte-unchanged and `needs:contract-review` is cleared on both carriers (record: `5737671517`). ⚠️ **Any count of this suite is only meaningful beside a statement of whether `packages/spec/dist` was built** — the two readings below are both correct, of different trees: | tree | Test Files | Tests | |:---|:---|:---| | **without** `packages/spec/dist` | `496 passed \| 1 skipped (497)` | `14562 passed \| 1 skipped (14563)` | | **with** `packages/spec/dist` built | `497 passed (497)` | `14564 passed (14564)` | The discriminator is `packages/spec/scripts/root-entry-type-nameability.pin.test.ts`, which takes a **dist-freshness branch at collection time** — ⛔ not a platform check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one test, `it.skipIf(!EXPECT_BUILT_DIST)(…)`, whose NAME carries the freshness state and the rerun command. Fresh ⇒ it registers two (the declaration-emit pin and its canary). `OS_EXPECT_ROOT_NAMEABILITY=1` does not cause the skip; it only turns the skip into a failure for a lane that expects a built dist. ⇒ `14562 + 1 skipped = 14563`, `14562 + 2 = 14564`. | check | result | |:---|:---| | `pnpm --filter @objectstack/spec test` | **0** — see the two readings above; the count depends on whether `dist` was built | | `pnpm --filter @objectstack/spec typecheck` | **0** | | `pnpm --filter @objectstack/spec build` | **0** | | `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced | | `pnpm --filter @objectstack/spec gen:openapi` | **0** — `openapi.json` byte-identical to base | | `pnpm --filter @objectstack/spec check:generated` | **0** — 16/16 generated artefacts current | | derived gate families (`scripts/pm/dispatch-gates.mjs --ran`) | **82 derived / 78 run, ALL exit 0 / 4 NOT MEASURED / 0 UNRUN** | The four NOT MEASURED are `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` — each exits **3** (`PREREQUISITE NOT MET`, a code that is explicitly neither pass nor failure) because each needs a whole-repo build closure that CI's Build Core / lint.yml produces. They are **declared, not skipped**. ⭐ The earlier count of 77/74/3 was taken **before the changeset file entered the change set**; the five families the changeset brings in (`check-empty-changeset` ×2, `release-rehearsal-clone --self-test`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`) all exit 0. Under-reporting a NOT MEASURED as "tested" is the exact inverse of this lane's reading discipline, and the PR body is where a reviewer reads the coverage claim. `packages/spec` has no workspace dependencies, so the dependency-closure build is empty; the public **entry** surface is unchanged (`src/shared/refinement-projection.ts` is not re-exported from `src/shared/index.ts`, which is why `check:api-surface` and `check:api-surface-declarations` both stay green with no artefact regeneration). ## Acceptance notes - **`dropped-refinements.baseline.json` is a shared hot file.** It is a generated, shrink-only ratchet that every holder regenerates, so a collision resolves by **regenerating** (`scripts/pm/os-regen-merge.sh`), ⛔ never by hand-editing conflict markers. This PR did not wait on it. - **F1 was fixed by MERGING, never rebasing.** `origin/main` was merged into the branch (merge `f66984fb1a`); ⛔ no history on this branch was rewritten. - **Noted, not filed — `scripts/build-schemas.ts:830` still carries a stale mention of the retired `api-surface-signatures.json`.** #19005's release named the next editor of that file as its carrier. This PR does not edit `build-schemas.ts` at all, so it does not become that carrier. Carrier: the next PR that edits `packages/spec/scripts/build-schemas.ts`. - **Noted, not filed — the `build-openapi.ts` branch still has no live sample.** Another seat measured that all nine schemas it projects read `declaredProjectable=0`. This arm's two sites are not among them, and `openapi.json` is byte-identical across this change. Carrier: whoever next teaches an arm a site that OpenAPI publishes. - **Receipt — Docs Drift Check on this head.** The bot derived 5 anchors from 1 changed package and found **no hand-written page naming any of them**; it also declares that `packages/spec/dropped-refinements.baseline.json` yielded **no anchor**, so pages documenting that file are **NOT COVERED by that run** — explicitly not a clean bill of health. Read and carried here rather than left unanswered: the ledger is a machine-maintained ratchet with no hand-written reference page to drift against, and this PR's edit to it is two row deletions plus a re-snapshot of its own `measured` block. ⚠️ It also notes its tree was the MERGE of this head into the base, not the head. - The test file's roster pin previously read "names exactly the two arms this change landed" while listing three; it now reads "the arms this list has landed, and nothing else". --- ## HISTORY — what this body used to say, kept rather than deleted ⛔ Three claims were carried by earlier revisions of this body and are **withdrawn**. They are recorded here because a correction that deletes its own subject is not a correction. 1. **「#19005 的发布说明写错了,那次普查走到 X 就停了」** — WITHDRAWN and refuted on the trees: the `dialect` predicate was introduced by #18638, **after** both `5e5ec9fa42` (#18952) and `72c1640504` (#19005). At those commits the slot carried zero custom checks and no ledger row, so both zeros were correct readings of their own trees. The correct statement is §0's: the candidate set is time-dependent. 2. **`Clause-②: no`** — WITHDRAWN. The claim comment declared `no`, which is wrong on the ruling's own axis: a published artefact narrows. `check-clause2-carriers` separately judged **C5 广化线索** at `src/shared/refinement-projection.ts` (the as-const `PROJECTABLE_REFINEMENT_PATTERNS` roster gaining `banned-keys`), and the precedent is exact: `required-one-of` (#18952) and `dependent-required` (#19005) both shipped `yes` for additions to that same array. ⚠️ The at-tier review then measured that roster to be an **internal** export that reaches no entry barrel, so the tell did not have to carry the verdict. Both carriers now declare `yes`, and all three carriers — claim, body, changeset — agree. 3. **`77 derived / 74 exit 0 / 3 exit 3`** — WITHDRAWN, superseded by §7's `82 / 78 / 4`. **Attribution (prose, because the edit side of a PR-body write always appends its own footer):** this body was written by the `domain:spec` PM seat in session `session_01AmH9bKvGoLjiY86Q4Z3og2`; the change itself was implemented by the dispatched dev on branch `claude/issue-18670-banned-keys-projection`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e872ef4 commit 5eebc9e

6 files changed

Lines changed: 323 additions & 17 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**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).
6+
7+
Clause-②: yes (narrowing)
8+
9+
One named pattern joins the closed list, and only one:
10+
11+
- **`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.
12+
13+
**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 202 entries / 553 sites to **200 entries / 551 sites**:
14+
15+
| row | before | after |
16+
|:---|:---|:---|
17+
| `system/TraceSamplingConfig` | `sites: ["composite.element.condition"]` | **deleted** — the schema drops nothing now |
18+
| `system/TracingConfig` | `sites: ["sampling.composite.element.condition"]` | **deleted** — the same node, reached through the parent |
19+
20+
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.
21+
22+
**⛔ 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.
23+
24+
**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.
25+
26+
<!-- 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. -->

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": 357,
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"
1050-
]
1051-
},
1052-
"system/TracingConfig": {
1053-
"sites": [
1054-
"sampling.composite.element.condition"
1055-
]
1056-
},
10571047
"ui/Action": {
10581048
"sites": [
10591049
"in",

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

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

167+
/**
168+
* `propertyNames` with a `not` over the banned names — the keyword JSON Schema
169+
* has for a rule about NAMES, so nothing is encoded and nothing approximated.
170+
*
171+
* A node that already carries `propertyNames` is conjoined through `allOf`
172+
* rather than overwritten, for the reason `emitRequiredOneOf` is: a record
173+
* emits `propertyNames: { type: 'string' }` of its own, and replacing it would
174+
* trade the rule this arm adds for the key-type rule the node already stated.
175+
* An identical rule already present is left alone rather than duplicated, so
176+
* the arm is idempotent the way `emitNonBlankString` is.
177+
*
178+
* An EMPTY key list emits nothing, and the reason is stronger than "it would
179+
* ban nothing": `enum` is specified as a non-empty array, so `{ not: { enum:
180+
* [] } }` is an INVALID SCHEMA rather than a vacuous one — ajv refuses it with
181+
* "enum must have non-empty array", which would take the whole published file
182+
* down with it instead of leaving a keyword nobody reads.
183+
*/
184+
function emitBannedKeys(jsonSchema: JsonObject, keys: readonly string[]): void {
185+
if (keys.length === 0) return;
186+
const rule = { not: { enum: [...keys] } };
187+
if (!('propertyNames' in jsonSchema)) {
188+
jsonSchema.propertyNames = rule;
189+
return;
190+
}
191+
if (JSON.stringify(jsonSchema.propertyNames) === JSON.stringify(rule)) return;
192+
const allOf = Array.isArray(jsonSchema.allOf) ? (jsonSchema.allOf as unknown[]) : [];
193+
if (allOf.some((clause) => JSON.stringify(clause) === JSON.stringify({ propertyNames: rule }))) return;
194+
jsonSchema.allOf = [...allOf, { propertyNames: rule }];
195+
}
196+
167197
/** Write one declared arm's keywords onto one emitted node. */
168198
export function emitProjectableRefinement(jsonSchema: JsonObject, declared: ProjectableRefinement): void {
169199
switch (declared.pattern) {
@@ -176,6 +206,9 @@ export function emitProjectableRefinement(jsonSchema: JsonObject, declared: Proj
176206
case 'dependent-required':
177207
emitDependentRequired(jsonSchema, declared.dependencies);
178208
return;
209+
case 'banned-keys':
210+
emitBannedKeys(jsonSchema, declared.keys);
211+
return;
179212
}
180213
}
181214

0 commit comments

Comments
 (0)