feat(spec)!: publish the banned-keys rule the tracing filter arm enforces - #19137
Conversation
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…rces
The published `system/TraceSamplingConfig.json` accepted `{ dialect: 'cel' }` at
`composite[].condition` while the runtime refused it — the card's own worked
instance of a published JSON Schema WIDER than the zod it is generated from.
Teach the closed projection list a fourth named pattern, `banned-keys`, and
declare the tracing slot's rule through it. Two ledger rows retired.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Two worktrees: (1) Derived judgments
(2) Semver level
(3) Findings⛔ F1 — BLOCKING — the head is measured against a base that Measured on ⛔ F2 — BLOCKING (one word) — the changeset still declares F3 — not blocking, ⛔ must not be adopted as written. See ①.9. The correct statement is that the candidate set is time-dependent, and the releasing seat's 「re-derive FIRST」 instruction is exactly what caught a candidate that arrived later. The PR body §0's 「corrects a reading in #19005's release note」 paragraph should be amended by the seat, ⛔ not propagated into the card's record. F4 — not blocking, for the card's worklist. Three published record nodes still accept Implemented-by: VERDICT: FAIL Generated by Claude Code Generated by Claude Code |
`#19084` (`ee5812a5e3`) retired the CEL expression arm of `TraceSamplingConfigSchema.composite[].condition` at the very slot this branch projects. Both intents stack: main's side of the slot is taken whole — the record-only `condition` and its retirement prescription — and its `!('dialect' in value)` predicate is declared through this branch's `bannedKeys(['dialect'])` arm. The two renamed ledger rows go, because the arm projects the site they name. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 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 29784a97f26424eefaa502a770fe21160d46e326 && git checkout 29784a97f26424eefaa502a770fe21160d46e326
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1047fe10166c943807d61218e2fde9c3ab22e502 184615ded95a9610055471de7c5214311158d787 && git checkout -B drift-repro 1047fe10166c943807d61218e2fde9c3ab22e502 && git merge --no-ff 184615ded95a9610055471de7c5214311158d787
node scripts/docs-audit/affected-docs.mjs --json 1047fe10166c943807d61218e2fde9c3ab22e502 |
`#19084` collapsed `TraceSamplingConfig.composite[].condition` to a record, so the ban lands on `condition` itself rather than on a union arm, the ledger rows are spelled `composite.element.condition`, and a CEL envelope is now refused by the runtime too. The live-seam pins and the changeset's accept-set sentence are re-derived on that tree. Also: the changeset declares `Clause-②: yes`, matching the corrected claim and the ruling; and the empty-key-list branch records the real reason it drops — `enum: []` is an invalid schema, not a vacuous rule. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ⭐ This review judges the rework head. The previously FAILed Four detached worktrees, each The four prior findings — all discharged
The contract questions
Findings
N2 — NOTED, no action. N3 — NOTED, a precision point, no action. The emitter docblock's 「 N4 — a reading disagreement, recorded rather than resolved. This review measured NOT MEASURED, declared
Instrument reachThe "0 disagreements" reading is ajv 8.20.0 in 2020-12 mode over 12 hand-chosen documents at this one slot, plus the test file's lattice. Outside that radius: any other validator's Implemented-by: VERDICT: PASS ⛔ Generated by Claude Code |
They keep no annotation: all three read `undecidable` to the detector rather than `dropped`, so they hold no ledger row and appear in no `x-dropped-refinements` — published yet unratcheted. One clause; the arm, the emitter, the ledger and the tests are untouched. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Gate cleared —
|
| 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 file is packages/spec/scripts/root-entry-type-nameability.pin.test.ts, and the mechanism is a dist-freshness branch taken 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) and both run. 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. Proven per file: that one file alone gives 1 skipped (1) with no dist and 1 passed (1) / 2 passed (2) after build. ⇒ 14562 + 1 skipped = 14563 and 14562 + 2 = 14564.
⇒ the reviewer's reading was of an unbuilt worktree and the dev's of a built one. Both stand. The dev's own account of its defect: the 497/14564 figure was reported without naming the precondition that produced it.
⭐ Noted, not filed, and worth carrying: that pin is honestly built — its skip states its own reason in the test name — but a skip is invisible in an aggregate totals line, which is exactly how two correct readings of this suite can differ by one file and two tests with nothing in either output saying so. ⇒ any count of this suite is only meaningful beside a statement of whether packages/spec/dist was built. Carrier: none filed; it is a reporting property of vitest totals, not a defect in the pin.
State after this comment
needs:contract-review off #18670 and off #19137. ⛔ The card keeps pm:dispatched: two of the three delivering PRs are merged and this one is still open, so de-labelling it would read as un-dispatched work and invite a second seat onto it.
Generated by Claude Code
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.jsonandsystem/TracingConfig.json. The published JSON Schema narrows toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ Theyesstands 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-surfaceandcheck:api-surface-declarationsboth exit 0 with no diff at all, becausesrc/shared/refinement-projection.tsis re-exported by no entry barrel and is not a.zod.ts, so it is not infiles[]. The C5 widening tell is real — the as-const rosterPROJECTABLE_REFINEMENT_PATTERNSgainsbanned-keysand an exportedbannedKeys()appears beside it — but that roster is an internalexport const, not the package's public entry surface. ⛔ Theyesdoes 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
184615ded9The 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 mergedorigin/main, soconditionis 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:anyOf[0]$-ban reaches ONE published node77 derived / 74 exit 0 / 3 exit 3Clause-②disagreement between the claim and the rulingyeson 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:
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
dialectpredicate 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/.checkcall expression underpackages/spec/src/**/*.ts(non-test), dumping each predicate's argument text — 114 custom-check call sites across 1008 source files (superRefine69,refine44,check1; 3.overwritecalls 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:collectDroppedRefinementsrun 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:
src/system/tracing.zod.ts(samplingcondition)!('dialect' in value)src/data/filter.zod.ts:1916!Object.keys(condition).some((key) => key.startsWith('$'))undecidable, 0 ledger rows, yet 3 published nodes.src/ui/action.zod.ts:1844Object.keys(hints).every((k) => known.has(k))data.params— not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes.src/data/driver/common.zod.ts:5371. The arm
banned-keys— "no document may carry any of these keys" — emitted aspropertyNameswith anotover the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced:src/shared/refinement-projection.tsdeclares the arm and builds the predicate from that declaration,scripts/lib/refinement-projection.tsemits it, and both halves still reachz.toJSONSchemathrough the one sharedprojectPublishedJsonSchemacall.The slot is a record, not a union. #19084 retired the CEL expression arm of
TraceSamplingConfigSchema.composite[].condition, so the node is now a singlez.record(z.string(), z.unknown())carrying the retirement's own refusal hook and itsabort: truemessage. The anonymous.refine((value) => !('dialect' in value))that guarded it is replaced by the declaredbannedKeys(['dialect'])— the retirement's prescription, error hook and message are taken frommainwhole, 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
propertyNamesjudges 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 anullvalue is present on both sides.⛔ The predicate reads OWN properties and never
key in value.inwalks the prototype chain, so a ban on a nameObject.prototypecarries —toString,constructor,valueOf— would refuse{}itself whilepropertyNamesaccepts it ('toString' in JSON.parse('{}')istrue). That is a disagreement about a JSON document, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spellsObject.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 underallOf, the same disciplineemitNonBlankStringfollows for an existingpattern, and the measuredformat-type.tshazard is untouched — a top-levelanyOfis still never written, and the reference renderer reads neitherallOfnorpropertyNames.An empty key list emits nothing, and for a stronger reason than "it would ban nothing":
enumis 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:system/TraceSamplingConfigsites: ["composite.element.condition"]system/TracingConfigsites: ["sampling.composite.element.condition"]…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, 129required-one-of, 2dependent-required, 2banned-keys— 9 undecidable.The
measuredblock is re-snapshotted from this run:refinementSitesThatDidProject367 → 357 andrefinementSitesWithNoJsonFormToCompare3 → 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:— "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:{ "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} }After:
{ "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {}, "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ] }and⚠️ that is #19084's retirement, not this PR, and this PR neither revives the expression arm nor extends the refusal;
x-dropped-refinementsis 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 —{ "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 toorigin/main, the generator re-run, and the two trees compared byte for byte.system/TraceSamplingConfig.json,system/TracingConfig.jsonThe diff of each moved file is exactly: gain the
allOfban, lose the matchingx-dropped-refinementsrow. Nothing else in either file changes. (The revert leg was proven on disk — each path's blob hash equalled itsorigin/mainblob — and the restore leg bygit diff HEADprinting nothing.)openapi.jsonwas measured separately and by the right instrument this time:gen:schemanever writes it, so the first comparison read two missing files and reported a false MOVED. Runninggen:openapion both trees gives a byte-identical file, sha25634b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaaon 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.mjsreplaced the one line dispatching the arm (emitBannedKeys(jsonSchema, declared.keys);) inscripts/lib/refinement-projection.ts, with the mutation verified against the disk (anchor 1 → 0, blob0a21fb6f9b66→6e55fe06cef5):refinement-projection.test.tsgen:schemacomposite.element.condition,sampling.composite.element.condition), each record/abortinggit diff HEADemptyThe 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:1916bans every key starting with$on a normalized field condition, and it reaches THREE published record nodes inpackages/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'sx-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
undecidableto the detector, becauseFieldOperatorsSchemacarriesz.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:1844stays dropped and annotated, correctly: its allowed key set is computed from the siblingdata.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 comment5737573936). The branch has moved once since, by exactly one prose clause in one changeset file (git diff --stat 384d27ac18 184615ded9→1 file changed, 1 insertion(+), 1 deletion(-)), so the contract surface the review judged is byte-unchanged andneeds:contract-reviewis cleared on both carriers (record:5737671517).packages/spec/distwas built — the two readings below are both correct, of different trees:packages/spec/dist496 passed | 1 skipped (497)14562 passed | 1 skipped (14563)packages/spec/distbuilt497 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=1does 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.pnpm --filter @objectstack/spec testdistwas builtpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec gen:schemapnpm --filter @objectstack/spec gen:openapiopenapi.jsonbyte-identical to basepnpm --filter @objectstack/spec check:generatedscripts/pm/dispatch-gates.mjs --ran)The four NOT MEASURED are
check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck: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/spechas no workspace dependencies, so the dependency-closure build is empty; the public entry surface is unchanged (src/shared/refinement-projection.tsis not re-exported fromsrc/shared/index.ts, which is whycheck:api-surfaceandcheck:api-surface-declarationsboth stay green with no artefact regeneration).Acceptance notes
dropped-refinements.baseline.jsonis 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.origin/mainwas merged into the branch (mergef66984fb1a); ⛔ no history on this branch was rewritten.scripts/build-schemas.ts:830still carries a stale mention of the retiredapi-surface-signatures.json. feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005's release named the next editor of that file as its carrier. This PR does not editbuild-schemas.tsat all, so it does not become that carrier. Carrier: the next PR that editspackages/spec/scripts/build-schemas.ts.build-openapi.tsbranch still has no live sample. Another seat measured that all nine schemas it projects readdeclaredProjectable=0. This arm's two sites are not among them, andopenapi.jsonis byte-identical across this change. Carrier: whoever next teaches an arm a site that OpenAPI publishes.packages/spec/dropped-refinements.baseline.jsonyielded 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 ownmeasuredblock.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.
dialectpredicate was introduced by feat(spec)!: every engine-evaluated expression slot requires a non-blanksource#18638, after both5e5ec9fa42(feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) and72c1640504(feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #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.Clause-②: no— WITHDRAWN. The claim comment declaredno, which is wrong on the ruling's own axis: a published artefact narrows.check-clause2-carriersseparately judged C5 广化线索 atsrc/shared/refinement-projection.ts(the as-constPROJECTABLE_REFINEMENT_PATTERNSroster gainingbanned-keys), and the precedent is exact:required-one-of(feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) anddependent-required(feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005) both shippedyesfor additions to that same array.yes, and all three carriers — claim, body, changeset — agree.77 derived / 74 exit 0 / 3 exit 3— WITHDRAWN, superseded by §7's82 / 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:specPM seat in sessionsession_01AmH9bKvGoLjiY86Q4Z3og2; the change itself was implemented by the dispatched dev on branchclaude/issue-18670-banned-keys-projection.Generated by Claude Code