Skip to content

feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call - #19005

Merged
os-elon-musk merged 4 commits into
mainfrom
claude/issue-18670-dependent-required-arm
Sep 18, 2026
Merged

os-elon-musk merged 4 commits into
mainfrom
claude/issue-18670-dependent-required-arm

Conversation

@os-elon-musk

Copy link
Copy Markdown
Collaborator

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, docs(spec): SYNC_ARCHITECTURE stops teaching retryConfig as the rate-limit remedy #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 spec: hold a predicate to what the engine can run; declare its fault semantics (ADR-0136) #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 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/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 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'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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 43f4766889e39d7a4590c5787d38e5956d0b4cb6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 75ab5edc19f21500eaee3f479c4c1ee8b65b9482 — the merge of head 6007a484a50cf8a985390a795cb69832f3df6af3 into base 43f4766889e39d7a4590c5787d38e5956d0b4cb6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review

129/129 Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6007a484a50cf8a985390a795cb69832f3df6af3

① Derived judgments

Isolated 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 get_session reads the parent — at 129 of 129 assistant turns, zero at any other model.

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:

  • data/SSLConfig.json at head carries dependentRequired in BOTH directions, {cert:[key], key:[cert]}, and the base row it retired was count: 1 — that one .refine() being hasCert === hasKey. The retired row corresponds to exactly that constraint and nothing more.
  • Ablation (c), the exact shape "row deleted while the drop persists" — arm emission silenced with the head ledger kept — drives the generator to exit 1, naming the undeclared schema and the miscounted entry. Ablation (a), the retired row re-inserted against pristine code, also exit 1. Pristine control exit 0. So the build itself adjudicates the ledger in both directions and a bad deletion cannot land.
  • Independently re-derived over a 5,003-document corpus with ajv: runtime verdict vectors byte-identical base and head (f78d5a62d223296f, 0dec47ca8fe169bb); published side narrowed 4 and 8, widened 0; the runtime-accepts-but-published-refuses asymmetry is 12 to 12 and 41 to 41, i.e. unchanged. Lit controls fired: a one-direction dependency map moves the runtime vector by 8 documents and the published vector by 2.

The remaining data/SQLDriverConfig "" site is a different rule — it judges the VALUE of ssl, which carries .default(false), so a dependentRequired would over-narrow and refuse documents the runtime accepts. Correctly left dropped, and not conflated with the retired row.

② Semver level

minor with a **BREAKING (published artifact narrows)** banner, line-initial Clause-②: yes (narrowing) in both the changeset and the PR body. Right rather than merely permitted: not in pre-mode, and the launch-window convention carries breaking-ness by banner plus ADR-0087 disposition. ADR-0087 D3 entry is not owed — the disposition is not-required (no-migration-prescription) per the maintainer ruling in comment 5725370614, matching the previous arm's precedent already on main. check-adr-0087-registration.mjs and check-changeset-no-major.mjs both exit 0 locally against an explicit base, and Check Changeset is green in CI.

③ Boundary flags

Governed surface: 0 of 11 paths hit the register, derived by check-governed-merges.mjs --pr 19005 three-dot by construction, not from a caller-supplied list and not recalled. Release-owned content/docs/releases/: 0 paths, lit control 2. Generated api-surface-declarations/** and api-surface/**: 0 paths, and not a stale omission — the moved module is not publicly re-exported. Gate-weakening: none. Floors unmoved, ratchet deletions only, zero tests skipped, disabled, quarantined or .only-ed (lit control: a synthetic it.skip line reads 1), it() blocks +16/−0, vitest 91/91 at head against 75/75 at base.

Implemented-by: claude/issue-18670-dependent-required-arm
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

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 objectstack.json and .build-input-hash-schema also moving derivatively; packages/spec/scripts/build-openapi.ts:95 is a second override-less projection path, which this seat is filing as its own card; and two in-module header sentences are now stale.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 129/129 CONTRACT_REVIEW_TIER
Head-sha: 6007a484a50cf8a985390a795cb69832f3df6af3

① Derived judgments

Isolated 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 get_session reads the parent — at 129 of 129 assistant turns, zero at any other model.

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:

  • data/SSLConfig.json at head carries dependentRequired in BOTH directions, {cert:[key], key:[cert]}, and the base row it retired was count: 1 — that one .refine() being hasCert === hasKey. The retired row corresponds to exactly that constraint and nothing more.
  • Ablation (c), the exact shape "row deleted while the drop persists" — arm emission silenced with the head ledger kept — drives the generator to exit 1, naming the undeclared schema and the miscounted entry. Ablation (a), the retired row re-inserted against pristine code, also exit 1. Pristine control exit 0. So the build itself adjudicates the ledger in both directions and a bad deletion cannot land.
  • Independently re-derived over a 5,003-document corpus with ajv: runtime verdict vectors byte-identical base and head (f78d5a62d223296f, 0dec47ca8fe169bb); published side narrowed 4 and 8, widened 0; the runtime-accepts-but-published-refuses asymmetry is 12 to 12 and 41 to 41, i.e. unchanged. Lit controls fired: a one-direction dependency map moves the runtime vector by 8 documents and the published vector by 2.

The remaining data/SQLDriverConfig "" site is a different rule — it judges the VALUE of ssl, which carries .default(false), so a dependentRequired would over-narrow and refuse documents the runtime accepts. Correctly left dropped, and not conflated with the retired row.

② Semver level

minor with a **BREAKING (published artifact narrows)** banner, line-initial Clause-②: yes (narrowing) in both the changeset and the PR body. Right rather than merely permitted: not in pre-mode, and the launch-window convention carries breaking-ness by banner plus ADR-0087 disposition. ADR-0087 D3 entry is not owed — the disposition is not-required (no-migration-prescription) per the maintainer ruling in comment 5725370614, matching the previous arm's precedent already on main. check-adr-0087-registration.mjs and check-changeset-no-major.mjs both exit 0 locally against an explicit base, and Check Changeset is green in CI.

③ Boundary flags

Governed surface: 0 of 11 paths hit the register, derived by check-governed-merges.mjs --pr 19005 three-dot by construction, not from a caller-supplied list and not recalled. Release-owned content/docs/releases/: 0 paths, lit control 2. Generated api-surface-declarations/** and api-surface/**: 0 paths, and not a stale omission — the moved module is not publicly re-exported. Gate-weakening: none. Floors unmoved, ratchet deletions only, zero tests skipped, disabled, quarantined or .only-ed (lit control: a synthetic it.skip line reads 1), it() blocks +16/−0, vitest 91/91 at head against 75/75 at base.

Implemented-by: claude/issue-18670-dependent-required-arm
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

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 objectstack.json and .build-input-hash-schema also moving derivatively; packages/spec/scripts/build-openapi.ts:95 is a second override-less projection path, which this seat is filing as its own card; and two in-module header sentences are now stale.


Supersedes comment 5729571884 on this same head. That record spelled the line 129/129 Served-tier: ..., putting the at-tier stamp control before the KEY, so the line did not begin with Served-tier: and --pair read it as absent (C7, exit 4). The stamp control may precede the CONSTANT, not the key. Nothing about the review changed — same head, same reviewer, same 129/129 transcript census, same verdict; only this line's spelling is repaired.


Generated by Claude Code

@os-elon-musk
os-elon-musk marked this pull request as ready for review September 18, 2026 11:52
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 72c1640 Sep 18, 2026
47 of 48 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-18670-dependent-required-arm branch September 18, 2026 12:16
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rces (objectstack-ai#19137)

Part of objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 | 「objectstack-ai#19005 的普查走到 X 就停了」 — an account of a sibling release being wrong
| **RETRACTED.** The candidate set is TIME-DEPENDENT; objectstack-ai#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 objectstack-ai#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.** objectstack-ai#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
objectstack-ai#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.** objectstack-ai#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; objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 **objectstack-ai#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`.** objectstack-ai#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. **「objectstack-ai#19005 的发布说明写错了,那次普查走到 X 就停了」** — WITHDRAWN and refuted on the
trees: the `dialect` predicate was introduced by objectstack-ai#18638, **after** both
`5e5ec9fa42` (objectstack-ai#18952) and `72c1640504` (objectstack-ai#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` (objectstack-ai#18952) and `dependent-required`
(objectstack-ai#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants