feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call - #19005
Conversation
…ion's two halves one call
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 <noreply@anthropic.com>
…sm fixes Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…pendent-required-arm
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 75ab5edc19f21500eaee3f479c4c1ee8b65b9482 && git checkout 75ab5edc19f21500eaee3f479c4c1ee8b65b9482
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43f4766889e39d7a4590c5787d38e5956d0b4cb6 6007a484a50cf8a985390a795cb69832f3df6af3 && git checkout -B drift-repro 43f4766889e39d7a4590c5787d38e5956d0b4cb6 && git merge --no-ff 6007a484a50cf8a985390a795cb69832f3df6af3
node scripts/docs-audit/affected-docs.mjs --json 43f4766889e39d7a4590c5787d38e5956d0b4cb6 |
Contract review129/129 Served-tier: ① Derived judgmentsIsolated at-tier review; this seat serves below the tier constant and does not self-review. Tier verified by census of the reviewer's own transcript — a subagent cannot self-attest, since its The decisive question was the ledger shrink: 201 to 200 entries, 553 to 551 sites, 197 to 199 projected, deletions only. A shrinking ratchet is either a defect fixed or a gate weakened, and the two are indistinguishable in the diff. It is a defect fixed, proven in both directions:
The remaining ② Semver level
③ Boundary flagsGoverned surface: 0 of 11 paths hit the register, derived by Implemented-by: VERDICT: PASS Non-blocking, carried and not fixed here: the PR body's "1530 of 1532" is an under-count, the true census being 1528 of 1532 identical, with Generated by Claude Code |
Contract reviewServed-tier: 129/129 ① Derived judgmentsIsolated at-tier review; this seat serves below the tier constant and does not self-review. Tier verified by census of the reviewer's own transcript — a subagent cannot self-attest, since its The decisive question was the ledger shrink: 201 to 200 entries, 553 to 551 sites, 197 to 199 projected, deletions only. A shrinking ratchet is either a defect fixed or a gate weakened, and the two are indistinguishable in the diff. It is a defect fixed, proven in both directions:
The remaining ② Semver level
③ Boundary flagsGoverned surface: 0 of 11 paths hit the register, derived by Implemented-by: VERDICT: PASS Non-blocking, carried and not fixed here: the PR body's "1530 of 1532" is an under-count, the true census being 1528 of 1532 identical, with Supersedes comment 5729571884 on this same head. That record spelled the line Generated by Claude Code |
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 landedrequired-one-ofandnon-blank-string.1. The arm:
dependentRequireddata/SSLConfig's refinement ishasCert === hasKey— preciselydependentRequired { cert: ['key'], key: ['cert'] }. It is emitted through the same closed-vocabulary mechanism the previous arm built:src/shared/refinement-projection.tsdeclares,scripts/lib/refinement-projection.tsemits. 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, anddependentRequiredtriggers on PRESENCE — so a key present with any JSON value,nullincluded, arms its dependency exactly as the predicate's!== undefineddoes. 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:data/SSLConfigsites: [""]data/SQLDriverConfigsites: ["", "sslConfig"]sites: [""]— thesslConfigsite closed1 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, 60non-blank-string, 2dependent-required— 3 undecidable.data/SQLDriverConfig's remaining""site is its own separate rule, "sslConfigis required whensslis true". That judges a VALUE, isif/thenrather than this arm, and correctly stays dropped and annotated.Banned keys (
propertyNames/not) — NOT taken, and not forcedConfirmed 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.paramHintsagainst the action's own params). Neither is mechanically derivable, so no candidate was constructed. This is why the body saysPart ofand carries no closing keyword.2. Mechanism fix A — the verdict is per NODE, the rules are per CHECK
verdictForcompared a node with ALL custom checks against the node with NONE, so any one declared arm marked the whole nodeprojected. Reproduced on the landed code before changing it: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:
projectednow requirescustoms.length === declaredPatterns.length; anything else isdroppedconservatively. The RAW differential is kept as a newprojectionMovedfield 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):total === statedguard54ed82dbe4c2to2c1bff777363dropped"54ed82dbe4c2,git diff HEADempty3. Mechanism fix B — generator/detector coupling, by construction
build-schemas.ts(threetoJSONSchemacalls) andprojectOrNulleach passed theoverrideindependently. Measured on the pristine base with only the generator's import stubbed out:shared/Expression.jsonallOfx-dropped-refinementsCensus 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 —
projectPublishedJsonSchemainscripts/lib/refinement-projection.ts. All three generator calls, the union-branch projector behind the third, and the detector's differential now reachz.toJSONSchemathrough it, andprojectByPruningUnionBranchesno longer takes anoverrideoption 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:
Expression.jsonallOfx-dropped-refinementsThe 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.jsonanddata/SQLDriverConfig.json, each gainingdependentRequiredand losing the matchingx-dropped-refinementsrow. Nothing else inpackages/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 threesslstates). Published-side verdicts computed with ajv 8.20.0 (draft 2020-12) against the two real snapshots.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) and4f18f835d4d1a62e(SQL) on both sides. The base leg was run against the real base blobs (git checkoutof the two source files atd8b12fca9, blob hashes asserted both ways, restore proven by an emptygit 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.jsonis the OUTPUT shape and listsrejectUnauthorizedas required because the runtime applies its.default(true). That asymmetry is pre-existing, is thex-ioconvention, and is unchanged by this PR — it is reported rather than netted out.5. Verification
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after theorigin/mainmerge (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 asPREREQUISITE 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.silentfor 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.scripts/refinement-projection.test.ts,scripts/dropped-refinements.test.ts,scripts/union-branch-projection.test.ts— 91 / 91.packages/spectypechecks clean (tsc --noEmitover both the package andtsconfig.scripts.json). The full@objectstack/specsuite reading is on the card.--format json. The population iseslint.config.mjs's ownfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']; the config states in its own words that this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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/mainmerged throughbash scripts/pm/os-regen-merge.sh(no rebase, no force-push). It brought one docs-only commit, docs(spec): SYNC_ARCHITECTURE stops teachingretryConfigas the rate-limit remedy #18979, overlapping none of this branch's paths and nomerge=os-regenpath. The previous arm's implementation body was asserted still present by quoted-exact-namegit grepagainstorigin/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 retiredapi-surface-signatures.json. The previous seat handed this to "the next editor ofbuild-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.content/docs/references/**, four pages of which open PR spec: hold a predicate to what the engine can run; declare its fault semantics (ADR-0136) #18985 edits — measured FALSE.dependentRequiredis a sibling keyword the reference renderer does not read,check:docsis green andcheck:generatedreports all 16 artifacts current. No reference page moves, so there is no collision with spec: hold a predicate to what the engine can run; declare its fault semantics (ADR-0136) #18985 on that directory.Reported for the seat to file (a candidate class-(b) finding, deliberately NOT fixed here):
packages/specshipssrc/**/*.zod.tsinfiles[], andscripts/check-published-files.mjsallows it with the reason "The Zod schemas are themselves the contract (Prime Directive Add metamodel interfaces for ObjectQL/ObjectUI contract #1); downstream code imports them directly, so these sources are product rather than build input." Two measurements contradict that reason: (1) the package'sexportsmap exposes no./src/*subpath and no wildcard, so no consumer can import those files at all; (2) 188 of the 202 shipped*.zod.tsfiles carry a relative import resolving to one of 35 modules undersrc/that the glob does NOT ship (src/shared/lazy-schema.tsalone 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 bynpm packand not by a real consumer import — that is the next step for whoever takes it.Generated by Claude Code