Repository navigation
Commit 72c1640
feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call (#19005)
Part of #18670 — item 2, the **third** of the ruling's four named arms.
#18670 remains open: banned keys is still untaken, and this body
deliberately carries no closing keyword for that number.
Clause-②: yes (narrowing)
Director ruling batch #154 item 3, letter **C** (comment 5725370614,
maintainer 「同意」): 「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`」.
Continues PR #18952 (squash `5e5ec9fa42194723cc523a274e7221c8447c4487`),
which landed `required-one-of` and `non-blank-string`.
## 1. The arm: `dependentRequired`
`data/SSLConfig`'s refinement is `hasCert === hasKey` — precisely
`dependentRequired { cert: ['key'], key: ['cert'] }`. It is emitted
through the same closed-vocabulary mechanism the previous arm built:
`src/shared/refinement-projection.ts` declares,
`scripts/lib/refinement-projection.ts` emits. No second mechanism was
introduced.
**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. The
dependency map is read once into the declaration and the predicate reads
it from there, so the published keyword and the enforced rule cannot
name different keys.
### Ledger: the rows retired, by name
`packages/spec/dropped-refinements.baseline.json`, **201 entries / 553
sites → 200 / 551**:
| row | before | after |
|:---|:---|:---|
| `data/SSLConfig` | `sites: [""]` | **deleted** — drops nothing now |
| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` —
the `sslConfig` site closed |
1 row deleted, 1 row shrunk, **2 sites closed, 0 sites added anywhere**;
the ledger diff is deletions only. Generator census after: 551 dropped
across 200 published schemas, **199 projected** — 137 `required-one-of`,
60 `non-blank-string`, **2 `dependent-required`** — 3 undecidable.
`data/SQLDriverConfig`'s remaining `""` site is its **own** separate
rule, "`sslConfig` is required when `ssl` is **true**". That judges a
VALUE, is `if`/`then` rather than this arm, and correctly stays dropped
and annotated.
### Banned keys (`propertyNames` / `not`) — NOT taken, and not forced
Confirmed against the tree, not assumed: the nearest sites judge a
banned VALUE on a string (`FILTER_ARRAY_LOGIC_KEYWORDS`) or an allowed
key set that is data-dependent (`ai.paramHints` against the action's own
params). Neither is mechanically derivable, so **no candidate was
constructed**. This is why the body says `Part of` and carries no
closing keyword.
## 2. Mechanism fix A — the verdict is per NODE, the rules are per CHECK
`verdictFor` compared a node with ALL custom checks against the node
with NONE, so any one declared arm marked the whole node `projected`.
Reproduced on the landed code before changing it:
```
mixed(declared+undeclared) dropped: [] projected: [{count: 2, declaredPatterns: ['non-blank-string']}]
undeclared-alone (lit) dropped: [{count: 1, declaredPatterns: []}] projected: []
declared-alone (lit) dropped: [] projected: [{count: 1, declaredPatterns: ['non-blank-string']}]
```
A second refinement on a declared node was therefore neither ledgered
nor annotated, and the generator's UNDECLARED line could not see it —
silently violating the ruling's own 「A refinement that is not one of
these named patterns stays dropped and annotated」.
**Fix:** `projected` now requires `customs.length ===
declaredPatterns.length`; anything else is `dropped` conservatively. The
RAW differential is kept as a new `projectionMoved` field so the
detector still MEASURES rather than asserts — collapsing it would have
made the instrument blind to the zod upgrade it exists to notice — and
the generator prints partially-stated sites on their own line.
**Ablation, both directions** (anchor-verified on disk,
`scripts/ablation-replace.mjs`):
| leg | blob | result |
|:---|:---|:---|
| mutated — drop the `total === stated` guard | `54ed82dbe4c2` to
`2c1bff777363` | **1 test red**, 42 green: "a DECLARED arm beside an
UNDECLARED rule stays `dropped`" |
| restored | back to `54ed82dbe4c2`, `git diff HEAD` empty | **43 / 43
green**; mutant text on disk 0, guard text 1 |
## 3. Mechanism fix B — generator/detector coupling, by construction
`build-schemas.ts` (three `toJSONSchema` calls) and `projectOrNull` each
passed the `override` independently. **Measured on the pristine base**
with only the generator's import stubbed out:
| leg | gate exit | `shared/Expression.json` `allOf` |
`x-dropped-refinements` | files carrying the non-blank pattern |
|:---|:---|:---|:---|:---|
| base, untouched (dark control) | 0 | present | absent | **35** |
| base, generator-side override dropped | **0 — GREEN** | **absent
(wide)** | **absent (SILENT)** | **0** |
Census identical to an untouched run (553 / 201 / 197). That is the
item-1 silence restored, standing behind a green ratchet — worse than
the state the card was filed about, because the ledger now certifies it.
A merge-conflict resolution was enough to cause it.
**Chosen fix: one shared projection helper** —
`projectPublishedJsonSchema` in `scripts/lib/refinement-projection.ts`.
All three generator calls, the union-branch projector behind the third,
and the detector's differential now reach `z.toJSONSchema` through it,
and `projectByPruningUnionBranches` no longer takes an `override` option
at all. There is no argument left for a caller to forget.
**Why the sandbox-builder pin was rejected**, not overlooked: a pin
*detects* after the fact and can be skipped, deleted or made vacuous,
and it leaves the two-argument shape in place so the next merge conflict
can still separate them. The choke point makes the one-sided failure
**unrepresentable** rather than caught. Both halves now lose the
override together or not at all — which is what turns the ablation from
silent into loud. The test file's own `publish()` helper was rewired
through the same call for the same reason, so the unit pins measure the
real seam rather than a re-spelling of it.
**Ablation, both directions:**
| leg | gate exit | `Expression.json` `allOf` | `x-dropped-refinements`
|
|:---|:---|:---|:---|
| mutated — override removed from the ONE helper | **1 — RED**: 46
undeclared schemas + 76 miscounted ledger entries | absent (wide) |
**present (annotated)** |
| restored | 0 | present | absent |
The contrast is the whole point: before, one-sided removal was green and
silent; now it is red **and** the file confesses.
## 4. Contract: the published file narrows toward what the runtime
already refuses
**Whole published tree, base vs head:** 1530 of 1532 files
byte-identical. The two that move are `data/SSLConfig.json` and
`data/SQLDriverConfig.json`, each gaining `dependentRequired` and losing
the matching `x-dropped-refinements` row. Nothing else in
`packages/spec/json-schema/**` changed.
**Parse-equivalence probe — 10,368 documents** (2,592 SSLConfig-shaped
over the full presence lattice of 4 keys times 6 value shapes including
`null`, a wrong type and an unrecognised extra key; 7,776
SQLDriverConfig documents embedding each of those under three `ssl`
states). Published-side verdicts computed with ajv 8.20.0 (draft
2020-12) against the two real snapshots.
| reading | SSLConfig | SQLDriverConfig |
|:---|--:|--:|
| documents | 2,592 | 7,776 |
| runtime accepts | 60 | 180 |
| published accepts, base | 27 | 54 |
| published accepts, head | 15 | 30 |
| **narrowed by this arm** | **12** | **24** |
| widened | 0 | 0 |
| **documents the runtime ACCEPTS that the published file now refuses**
| **0** | **0** |
**Runtime behaviour did not move.** The runtime verdict vector is
byte-identical at merge base and head over all 10,368 documents — sha
`9e7c848f04e0c687` (SSL) and `4f18f835d4d1a62e` (SQL) on both sides. The
base leg was run against the real base blobs (`git checkout` of the two
source files at `d8b12fca9`, blob hashes asserted both ways, restore
proven by an empty `git diff HEAD`), not against a retyped predicate.
**LIT CONTROL for that zero** — weakening the dependency map to one
direction (`{ cert: ['key'] }`) moves **96 documents** (24 SSL + 72 SQL)
and lifts runtime accepts from 60 to 84 and 180 to 252. The zero is a
reading, not a silence.
Note the published-accepts figures sit below runtime-accepts on both
sides: `SSLConfig.json` is the OUTPUT shape and lists
`rejectUnauthorized` as required because the runtime applies its
`.default(true)`. That asymmetry is pre-existing, is the `x-io`
convention, and is unchanged by this PR — it is reported rather than
netted out.
## 5. Verification
- **Gates:** derived from the merge base with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, re-derived after the `origin/main` merge (identical, **84
commands**). Every exit code captured by redirecting to a file first,
never through a pipe. **80 exit 0, 0 findings.** The remaining 4 —
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt` — exit **3**, which
those gates define as `PREREQUISITE NOT MET` ("Nothing was measured ...
It is NOT a finding"): each reads BUILT output of packages outside this
diff. They are **NOT MEASURED**, not red; the re-run against a full
build is reported on the card.
- The derivation's own caveats are carried, not netted out: 50
artifact-roster families score `silent` for every card in the tree, 11
declare a population too wide to place, 5 take a value from the
workflow, and 5 path-scheduled CI jobs run 30 steps with no local
invocation. None of those is a clearance, and CI owns them.
- **`pnpm --filter @objectstack/spec check:generated`:** all 16
generated artifacts up to date. **`content/docs/references/**` does not
move** — see acceptance notes.
- **Targeted tests:** `scripts/refinement-projection.test.ts`,
`scripts/dropped-refinements.test.ts`,
`scripts/union-branch-projection.test.ts` — **91 / 91**. `packages/spec`
typechecks clean (`tsc --noEmit` over both the package and
`tsconfig.scripts.json`). The full `@objectstack/spec` suite reading is
on the card.
- **Lint, declared narrowing:** eslint run over the 9 changed lintable
files, 0 errors / 0 warnings, file count read from `--format json`. The
population is `eslint.config.mjs`'s own `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the config states in its own
words that this repo "never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules) for ANY
file", so this diff cannot move the verdict on a file it does not touch.
The repo-wide sweep is CI's run.
- `origin/main` merged through `bash scripts/pm/os-regen-merge.sh` (no
rebase, no force-push). It brought one docs-only commit, #18979,
overlapping none of this branch's paths and no `merge=os-regen` path.
The previous arm's implementation body was asserted still present by
quoted-exact-name `git grep` against `origin/main`, with a dark control
at 0.
## Acceptance notes
Noted, not filed — out of scope for this card and not one of the three
filable classes:
- `packages/spec/scripts/build-schemas.ts` (the authorable-surface
docblock, near line 846) still names the retired
`api-surface-signatures.json`. The previous seat handed this to "the
next editor of `build-schemas.ts`", which is this PR. It is left
untouched deliberately: it is a stale code comment, not a defect, a
contract violation or an authoring trap, and the bounded in-place
exemption requires the finding to be **the same defect class as this
card**, which it is not. Carrier: the next PR that edits that docblock
for its own reasons.
- The dispatch's overlap warning — that a new keyword might move
`content/docs/references/**`, four pages of which open PR #18985 edits —
**measured FALSE**. `dependentRequired` is a sibling keyword the
reference renderer does not read, `check:docs` is green and
`check:generated` reports all 16 artifacts current. No reference page
moves, so there is no collision with #18985 on that directory.
Reported for the seat to file (a candidate class-(b) finding,
deliberately NOT fixed here):
- `packages/spec` ships `src/**/*.zod.ts` in `files[]`, and
`scripts/check-published-files.mjs` allows it with the reason "The Zod
schemas are themselves the contract (Prime Directive #1); **downstream
code imports them directly**, so these sources are product rather than
build input." Two measurements contradict that reason: (1) the package's
`exports` map exposes no `./src/*` subpath and no wildcard, so no
consumer can import those files at all; (2) 188 of the 202 shipped
`*.zod.ts` files carry a relative import resolving to one of 35 modules
under `src/` that the glob does NOT ship (`src/shared/lazy-schema.ts`
alone is imported by 181 of them), so they would not resolve even if
reachable. Overwhelmingly pre-existing and far outside this card; this
PR adds the third importer of one of those 35. Not verified by `npm
pack` and not by a real consumer import — that is the next step for
whoever takes it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d44fa19 commit 72c1640
11 files changed
Lines changed: 587 additions & 99 deletions
File tree
- .changeset
- packages/spec
- scripts
- lib
- src
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
7 | | - | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
738 | 738 | | |
739 | 739 | | |
740 | 740 | | |
741 | | - | |
742 | | - | |
743 | | - | |
744 | | - | |
745 | | - | |
746 | | - | |
747 | 741 | | |
748 | 742 | | |
749 | 743 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
| 52 | + | |
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| |||
498 | 498 | | |
499 | 499 | | |
500 | 500 | | |
501 | | - | |
502 | | - | |
503 | | - | |
504 | | - | |
| 501 | + | |
505 | 502 | | |
506 | 503 | | |
507 | 504 | | |
508 | 505 | | |
509 | | - | |
510 | | - | |
511 | | - | |
512 | | - | |
513 | | - | |
| 506 | + | |
514 | 507 | | |
515 | 508 | | |
516 | 509 | | |
| |||
526 | 519 | | |
527 | 520 | | |
528 | 521 | | |
529 | | - | |
530 | | - | |
531 | | - | |
532 | | - | |
| 522 | + | |
533 | 523 | | |
534 | 524 | | |
535 | 525 | | |
| |||
3603 | 3593 | | |
3604 | 3594 | | |
3605 | 3595 | | |
| 3596 | + | |
| 3597 | + | |
| 3598 | + | |
| 3599 | + | |
| 3600 | + | |
| 3601 | + | |
| 3602 | + | |
| 3603 | + | |
| 3604 | + | |
| 3605 | + | |
| 3606 | + | |
| 3607 | + | |
| 3608 | + | |
| 3609 | + | |
| 3610 | + | |
| 3611 | + | |
| 3612 | + | |
| 3613 | + | |
| 3614 | + | |
| 3615 | + | |
| 3616 | + | |
| 3617 | + | |
| 3618 | + | |
| 3619 | + | |
| 3620 | + | |
| 3621 | + | |
| 3622 | + | |
| 3623 | + | |
| 3624 | + | |
| 3625 | + | |
| 3626 | + | |
| 3627 | + | |
| 3628 | + | |
| 3629 | + | |
| 3630 | + | |
3606 | 3631 | | |
3607 | 3632 | | |
3608 | 3633 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
229 | 237 | | |
230 | 238 | | |
231 | 239 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
93 | 94 | | |
94 | | - | |
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| |||
149 | 149 | | |
150 | 150 | | |
151 | 151 | | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
152 | 164 | | |
153 | 165 | | |
154 | 166 | | |
| |||
221 | 233 | | |
222 | 234 | | |
223 | 235 | | |
224 | | - | |
225 | | - | |
226 | | - | |
227 | | - | |
228 | | - | |
229 | | - | |
230 | | - | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
231 | 250 | | |
232 | 251 | | |
233 | 252 | | |
234 | 253 | | |
235 | | - | |
236 | | - | |
237 | | - | |
| 254 | + | |
238 | 255 | | |
239 | 256 | | |
240 | 257 | | |
241 | 258 | | |
242 | 259 | | |
243 | 260 | | |
244 | 261 | | |
245 | | - | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
246 | 304 | | |
247 | | - | |
| 305 | + | |
248 | 306 | | |
249 | 307 | | |
250 | | - | |
251 | | - | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
252 | 313 | | |
253 | 314 | | |
254 | 315 | | |
| |||
396 | 457 | | |
397 | 458 | | |
398 | 459 | | |
| 460 | + | |
399 | 461 | | |
400 | 462 | | |
401 | 463 | | |
402 | 464 | | |
403 | 465 | | |
404 | | - | |
| 466 | + | |
405 | 467 | | |
| 468 | + | |
406 | 469 | | |
407 | 470 | | |
408 | 471 | | |
| |||
0 commit comments