Skip to content

feat(spec)!: publish the banned-keys rule the tracing filter arm enforces - #19137

Merged
os-steve merged 7 commits into
mainfrom
claude/issue-18670-banned-keys-projection
Sep 19, 2026
Merged

os-steve merged 7 commits into
mainfrom
claude/issue-18670-banned-keys-projection

Conversation

@os-steve

@os-steve os-steve commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Part of #18670 — item 2, the fourth of the ruling's four named arms: banned keys. This body carries no closing keyword for that number on purpose: measured banned-key sites are still unprojected (§6), and whether the card closes is the seat's call rather than this PR's.

Clause-②: yes

Carrier: the published artefacts packages/spec/json-schema/system/TraceSamplingConfig.json and system/TracingConfig.json. The published JSON Schema narrows toward what the runtime already refuses, and no document the runtime accepts becomes refused. ⭐ The yes stands on the ruling's own axis — a published artefact narrows — and the at-tier review measured that it stands there independently of the C5 tell: check:api-surface and check:api-surface-declarations both exit 0 with no diff at all, because src/shared/refinement-projection.ts is re-exported by no entry barrel and is not a .zod.ts, so it is not in files[]. The C5 widening tell is real — the as-const roster PROJECTABLE_REFINEMENT_PATTERNS gains banned-keys and an exported bannedKeys() appears beside it — but that roster is an internal export const, not the package's public entry surface. ⛔ The yes does not depend on it either way.

Director ruling batch #154 item 3, letter C (maintainer 「同意」, 2026-09-18T04:56Z): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as x-dropped-refinements」.


⛔ This body was REPLACED WHOLESALE by the seat, and last refreshed at 2026-09-19T00:07Z for head 184615ded9

The delivering dev writes a PR body once, at creation, and ⛔ does not patch it; a later correction is named in its report for the seat to write. That convention met a case it does not cover: the tree the first body described no longer exists. PR #19084 (ee5812a5e3) retired the CEL expression arm at this very slot before this branch merged origin/main, so condition is now a plain record and not a union — and the union framing ran through §0, §1, §3 and §4 alike. A patch of some sections would have left the artefact self-contradictory about the only tree it can land on, so the seat replaced it rather than appending a third correction block.

Five things were stale, and each is now stated for head 384d27ac18:

# was now
1 the slot framed as a UNION, the ban emitted into anyOf[0] a RECORD; the ban is conjoined onto it directly (§1, §3)
2 #19005 的普查走到 X 就停了」 — an account of a sibling release being wrong RETRACTED. The candidate set is TIME-DEPENDENT; #19005 read its own tree correctly (§0)
3 the $-ban reaches ONE published node THREE, each measured and named (§6)
4 77 derived / 74 exit 0 / 3 exit 3 82 derived / 78 run, all exit 0 / 4 NOT MEASURED (§7)
5 a live Clause-② disagreement between the claim and the ruling settled at yes on both carriers, and the claim comment carries the correction

⛔ Item 4 and item 5 were the seat's errors, not the dev's: the dev copied the claim line verbatim as the dual carrier requires, and only the seat writes claims and labels. Item 2 was the dev's, and the dev retracted it itself on measurement. The retracted text is preserved at the end of this body as HISTORY rather than deleted.


0. The pre-condition the releasing seat set — and the answer

The release of #19005 set a hard gate on whoever took this card next:

Whoever takes it next must re-derive the banned-keys candidate set FIRST and, if it is still empty, return the card rather than dispatching a dev to find nothing.

Re-derived. The set is NOT empty, and its clean member is the card's own worked instance.

The candidate set is TIME-DEPENDENT, and that is the whole reason the pre-condition was worth setting. #19005's census recorded zero clean candidates, and that was a correct reading of its own tree — the dialect predicate at this slot did not exist yet; it arrived with #18638, hours later. The instruction to re-derive the set FIRST is exactly what caught a candidate that landed after the last census, and it is the reason this card had work in it at all. ⛔ No sibling release was wrong; an earlier draft of this body said one was, and that claim is withdrawn.

Instrument: a TypeScript-AST scan of every .refine / .superRefine / .check call expression under packages/spec/src/**/*.ts (non-test), dumping each predicate's argument text — 114 custom-check call sites across 1008 source files (superRefine 69, refine 44, check 1; 3 .overwrite calls excluded, they are not custom checks). LIT CONTROL: 6 of those call sites spell an already-declared arm (requiredOneOf ×2, NON_BLANK_STRING ×3, dependentRequired ×1), so the scan does see the population it is supposed to see.

Radius, by form: source text of tracked files. A known target outside it: whether a given call site's node is a ledger row — the ledger's sites are computed at run time by the detector against packages/spec/json-schema/**, which is gitignored and returns 0 tracked entries. That is precisely why the earlier shape-only reading on this card was recorded as "not a reading". So the population question was answered with the instrument that can see it: collectDroppedRefinements run over the live schemas, plus the generator's own census.

Result — 4 of the 114 predicates judge KEYS at all, and they split three ways:

call site predicate verdict
src/system/tracing.zod.ts (sampling condition) !('dialect' in value) clean candidate — a static, self-contained, finite key ban. 2 ledger rows.
src/data/filter.zod.ts:1916 !Object.keys(condition).some((key) => key.startsWith('$')) an open key set — not this arm (§6). Detector verdict undecidable, 0 ledger rows, yet 3 published nodes.
src/ui/action.zod.ts:1844 Object.keys(hints).every((k) => known.has(k)) allowed keys computed from the sibling data.params — not mechanically derivable; stays dropped and annotated, exactly as the ruling prescribes.
src/data/driver/common.zod.ts:537 credential leaks at named paths judges values, not key names. Not this pattern.

1. The arm

banned-keys — "no document may carry any of these keys" — emitted as propertyNames with a not over the banned names. Same closed-vocabulary mechanism the three landed arms use, no second one introduced: src/shared/refinement-projection.ts declares the arm and builds the predicate from that declaration, scripts/lib/refinement-projection.ts emits it, and both halves still reach z.toJSONSchema through the one shared projectPublishedJsonSchema call.

The slot is a record, not a union. #19084 retired the CEL expression arm of TraceSamplingConfigSchema.composite[].condition, so the node is now a single z.record(z.string(), z.unknown()) carrying the retirement's own refusal hook and its abort: true message. The anonymous .refine((value) => !('dialect' in value)) that guarded it is replaced by the declared bannedKeys(['dialect']) — the retirement's prescription, error hook and message are taken from main whole, and only the predicate is declared. ⛔ The retirement's behaviour is unchanged by this PR; what changes is that the rule now has a published form.

Exact, not approximate. A JSON object's properties are exactly its own enumerable string-keyed ones, and propertyNames judges exactly those names — so "none of the banned names is an own property" and "no property name is one of the banned names" are one sentence read from two ends. It is presence and never value: a banned key present with a null value is present on both sides.

The predicate reads OWN properties and never key in value. in walks the prototype chain, so a ban on a name Object.prototype carries — toString, constructor, valueOf — would refuse {} itself while propertyNames accepts it ('toString' in JSON.parse('{}') is true). That is a disagreement about a JSON document, not an edge outside the domain, and it is pinned in both directions. The shipped predicate spells Object.prototype.hasOwnProperty.call(value, key) for that reason.

The emitted keywords are conjoined, never substituted. The node is a record and already states propertyNames: { type: 'string' } of its own; replacing it would trade a key-TYPE rule for a key-NAME rule, which is a narrowing paid for with a widening. The ban goes under allOf, the same discipline emitNonBlankString follows for an existing pattern, and the measured format-type.ts hazard is untouched — a top-level anyOf is still never written, and the reference renderer reads neither allOf nor propertyNames.

An empty key list emits nothing, and for a stronger reason than "it would ban nothing": enum is specified as a non-empty array, so { not: { enum: [] } } is an invalid schema rather than a vacuous one — ajv refuses it with "enum must have non-empty array", which would take the whole published file down instead of leaving a keyword nobody reads. The declaring signature takes a non-empty tuple, so the guard is belt-and-braces at a seam two files apart.

2. The rows retired, by name

packages/spec/dropped-refinements.baseline.json, 202 entries / 553 sites → 200 / 551:

row before after
system/TraceSamplingConfig sites: ["composite.element.condition"] deleted — drops nothing now
system/TracingConfig sites: ["sampling.composite.element.condition"] deleted — the same node, reached through the parent

⚠️ Both paths are the post-retirement spellings. On the tree this PR was first written against they read …condition.options[0], because the node was then a union arm; #19084 renamed them by making the node a record, and the rows deleted here are the renamed ones. 2 rows deleted, 0 shrunk, 2 sites closed, 0 sites added anywhere; the ledger diff is deletions only.

Generator census after: 551 dropped across 200 published schemas, 357 projected — 224 non-blank-string, 129 required-one-of, 2 dependent-required, 2 banned-keys — 9 undecidable.

The measured block is re-snapshotted from this run: refinementSitesThatDidProject 367 → 357 and refinementSitesWithNoJsonFormToCompare 3 → 9. ⛔ This PR moved neither number. The projected total fell because #19084 retired expression arms elsewhere in the tree; the main-tip block was already stale on its own tree. Re-snapshotting is what this PR owes for editing the file at all, and it is not a reading this arm produced.

3. The card's own worked instance, before and after

The issue body cites system/TraceSamplingConfig.json:

condition.anyOf[0] = {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}

— "That accepts {dialect:'cel'} — which the runtime refuses." The union wrapper is gone with #19084; the same record is now the node itself, and on the merge base it publishes unchanged in substance:

{ "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} }

After:

{
  "type": "object",
  "propertyNames": { "type": "string" },
  "additionalProperties": {},
  "allOf": [ { "propertyNames": { "not": { "enum": ["dialect"] } } } ]
}

and x-dropped-refinements is gone from both artefacts. Measured at the slot: { "dialect": "cel" } is refused by the runtime and now by the file; { "dialect": "cel", "source": "record.amount > 10" } is refused by both sides — ⚠️ that is #19084's retirement, not this PR, and this PR neither revives the expression arm nor extends the refusal; { "amount": { "$gt": 10 } } is accepted by both; {} and { "service": "api" } are accepted by both; { "dialect": null } is refused by both.

4. Blast radius, measured on the whole published tree

Re-measured on the new base (aadea24b89): the three edited source files were reverted to origin/main, the generator re-run, and the two trees compared byte for byte.

reading value
per-schema files common to both trees 1530
byte-identical 1528
moved 2system/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 0a21fb6f9b666e55fe06cef5):

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 384d27ac18 184615ded91 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 0openapi.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. 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 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. feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005 的发布说明写错了,那次普查走到 X 就停了」 — WITHDRAWN and refuted on the trees: the dialect predicate was introduced by feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638, after both 5e5ec9fa42 (feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) and 72c1640504 (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.
  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 (feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) and dependent-required (feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #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

…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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5a79d7a04acb7d7b477ebfaf8d904872e978961f

Two worktrees: base at merge-base c70581bc8e, head at 5a79d7a04a; plus head-ablate and main-tip at origin/main aadea24b89. Driver-free mergeability probed from a bare shared clone. Instruments: node v22.22.2, pnpm 10.31.0, zod 4.4.3, typescript 6.0.3 (AST census), ajv 8.20.0 draft-2020-12 as the JSON Schema validator, vitest 4.1.11. ⭐ Every dist read was built by the direct package script in the head worktree — no turbo, nothing cache-served. Exit codes captured before every pipe.

(1) Derived judgments

  1. Runtime accept set at the slot: UNCHANGED. 10 documents through TraceSamplingConfigSchema.safeParse give identical verdicts on base and head — including the prototype-name and __proto__-as-own-key edges. The predicate's move from 'dialect' in value to hasOwnProperty moves no JSON document. Correct.
  2. ⭐ The card's headline consequence IS delivered — measured on the published artefact with ajv, not on the source. Base system/TraceSamplingConfig.json: condition.anyOf[0] accepts, runtime refuses ⇒ 3 disagreements ({dialect:"cel"}, {dialect:null}, {dialect:"cel",ast:{}}). Head: the node gains allOf:[{propertyNames:{not:{enum:["dialect"]}}}], x-dropped-refinements gone ⇒ 0 disagreements / 10. Same reading on TracingConfig.json. Correct.
  3. allOf conjunction. The record's own propertyNames:{type:"string"} is kept and the ban conjoined; a second identical emit leaves allOf length 1; an empty key list leaves the node byte-identical; two different bans both land. ⚠️ Correction to the seat's brief: an empty not.enum would not 「refuse {}」 — it is an invalid schema (ajv: enum must have non-empty array), so dropping it is right for a different reason.
  4. Public export surface: unchanged. check:api-surface exit 0; check:api-surface-declarations exit 0 with no diff at all (no order-only shard noise to discount). refinement-projection.ts is re-exported by no entry barrel and is not a .zod.ts, so not in files[]. The C5 tell at :141 is an internal export const; Clause-②: yes stands on the ruling's own axis independently of that tell.
  5. Blast radius. Per-schema files common to both trees: 1530; byte-identical 1528; moved 2, each diff exactly the allOf gain plus the x-dropped-refinements loss. gen:openapi on both: openapi.json byte-identical, sha256 34b1dc9c… — the same value seat 2 read at 19:14Z. Correct.
  6. Ledger 202/553 → 200/551, deletions only, 0 sites added.
  7. Ablation. Mutating the one dispatch line: refinement-projection.test.ts exit 1, 12 failed / 47 passed; gen:schema exit 1 naming both schemas as dropping an undeclared refinement. Restore proven by blob. The two rows and the emitter are coupled.
  8. ⭐ Candidate set — re-derived independently. Own TS-AST scan: 1008 files, 114 sites (superRefine 69 / refine 44 / check 1), matching the dev exactly; lit control 6 on base, 7 on head. Second pass, comments stripped, genuine key-presence forms only: exactly 4 — a credential VALUE check (not a key ban), filter.zod.ts:1916 (open $ ban), tracing.zod.ts:388/389 (the one clean finite ban), and action.zod.ts:1786 (set computed from a sibling field). The set is non-empty and its clean member is the card's worked instance.
  9. ⭐ The 「superseded reading」 the dev corrected — the correction is itself WRONG (F3). At 5e5ec9fa42 (feat(spec)!: publish the two named refinement patterns the runtime already enforces #18952) and 72c1640504 (feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call #19005) the slot read z.union([z.record(…), ExpressionInputSchema]) with zero custom checks and no ledger row. The 'dialect' in value predicate was introduced by feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638 (ce5785790c, 15:32:17Z). ⇒ The earlier zeros were correct readings of their trees; the population did not exclude the slot — the slot had no refinement to census.
  10. What is left open (§6). filter.zod.ts:1916 reaches three published record nodes in data/NormalizedFilter.json, not the one the body names, all read undecidable by the detector, so the ledger holds 0 rows for them. Measured: the file PASSes {"$not":{"$bogus":{"$gt":1}}} while the runtime refuses. Leaving it out of this arm is right — a finite enum cannot express an open set.

(2) Semver level

@objectstack/spec: minor, BREAKING banner, adr-0087: not-required (no-migration-prescription), rows named — matches the ruling and the launch-window convention. check-adr-0087-registration, check-empty-changeset, check-changeset-no-major all exit 0. Two defects in the same file: F1 and F2.

(3) Findings

⛔ F1 — BLOCKING — the head is measured against a base that main has moved past AT THE VERY SLOT. origin/main (aadea24b89, 7 commits ahead of the merge-base) carries #19084 (ee5812a5e3, 22:01:46Z — 31 minutes before this PR opened), which retired the CEL expression arm of TraceSamplingConfigSchema.composite[].condition. It is the only commit since the merge-base touching this PR's files. Driver-free merge-tree: content conflicts in system/tracing.zod.ts and dropped-refinements.baseline.json (the API's mergeable_state: dirty agrees).

Measured on main-tip: the slot is now z.record(…).refine(…, {abort:true}) alone — the generated condition has no anyOf, the ledger rows are spelled composite.element.condition, and the runtime refuses the envelope. Consequences on the head as written: (a) the changeset sentence 「…is still accepted by the file, through the union's expression arm, which is untouched」 is false on the only tree it can land on, and would ship in CHANGELOG.md; (b) the live-seam pins read condition.anyOf[0] — false on main; (c) the two ledger rows to retire are now different strings. ⭐ The arm's mechanism needs no change. Required: rebase, regenerate, delete the renamed rows, re-derive the pins on a record-only slot (the ban lands directly on condition, still conjoined through allOf), rewrite the changeset's accept-set sentence, re-measure the radius on the new base.

⛔ F2 — BLOCKING (one word) — the changeset still declares Clause-②: no. The claim and PR body were corrected to yes; the ruling writes yes; precedent #19005's changeset reads yes (narrowing). ⚠️ No local gate reads that line for its value, so it will not be caught mechanically — and it is the carrier that ships.

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 $-prefixed keys, all outside the ledger's reach because the detector reads them undecidable while the branch-pruning path still publishes them. Published-yet-undecidable sites are a ratchet blind spot worth its own line on the card.

⚠️ CI green is NOT MEASURED here: the API returns 0 check runs for this head.

Implemented-by: claude/issue-18670-banned-keys-projection
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 1047fe10166c943807d61218e2fde9c3ab22e502packageMentionDocs.

Which tree this was computed on

This run read content/docs from 29784a97f26424eefaa502a770fe21160d46e326 — the merge of head 184615ded95a9610055471de7c5214311158d787 into base 1047fe10166c943807d61218e2fde9c3ab22e502, 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 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

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

`#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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 384d27ac185ac9415f29851784024f7b638b89e1

⭐ This review judges the rework head. The previously FAILed 5a79d7a04a is an ancestor of it (git merge-base --is-ancestor exit 0), so no history was rewritten.

Four detached worktrees, each pnpm install --frozen-lockfile exit 0: head (384d27a), base (aadea24), ablate, and pre-19084 (ee5812a5e3^). Shared checkout untouched. Instruments: node v22.22.2, zod 4.4.3, ajv 8.20.0 draft-2020-12, vitest. Every exit code captured before any pipe.

The four prior findings — all discharged

The contract questions

  • Exact, not approximate — 0 runtime/file disagreements on head (base: 5, all dialect-bearing). 12 documents through ajv on the whole published file, the node alone, and safeParse: {}, {"service":"api"}, {"dialect":"cel"}, {"dialect":"cel","source":…}, {"dialect":null}, {"amount":{"$gt":10}}, {"Dialect":"cel"}, {"__proto__":{"dialect":"cel"}}, a duplicate-key document, {"dialect ":"x"}, {"":"x"}, {"dialect":{}}. The runtime column is identical on both trees — the rewrite from 'dialect' in value to hasOwnProperty moves no JSON document, because zod 4.4.3's record parser copies only own enumerable string keys into the value the refine sees.
  • Conjunction, never substitution. Tree-wide on head: 4 occurrences of propertyNames.not, all insideAllOf: true (base: 0). The direct-assignment branch is exercised nowhere in the published tree, so no node's contract changes a second way, and none loses a pre-existing propertyNames.
  • Accept-set movement is one-directional. File side moves only accept → refuse, on five documents the runtime already refused on base. ⛔ No document the runtime accepts becomes file-refused. The CEL envelope's refusal is spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition #19084's retirement, correctly attributed by the body.
  • Ablation — both legs fail. Deleting the one dispatch line: test file exit 1, 12 failed / 46 passed; gen:schema exit 1 naming both renamed rows as undeclared gaps. The row deletion is load-bearing.
  • The tests pin mechanism. Runtime/file agreement is asserted by equality over an 8-mask presence lattice, through a helper that throws when no propertyNames.not.enum is present — it cannot pass vacuously. The live seam runs the real schema through the same choke point the generator uses.
  • measured block attribution confirmed, and sharpened. pre-19084 measures 367 projected / 9 undecidable; base measures 355 / 9; head 357 / 9. ⇒ the 367→355 drop is spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition #19084's (8 non-blank + 4 required-one-of, the two retired expression arms), exactly as the body says. Sharpening: the undecidable count was already 9 before spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition #19084, so the 3 was stale earlier than spec: retire the CEL expression arms of SLI successCriteria and composite trace-sampling condition #19084.

Findings

⚠️ N1 — NOTED, and the seat is acting on it: the changeset carries a sentence that is FALSE on the tree, and the changeset ships to CHANGELOG.md. It says the $-ban sites 「stay unprojected and keep their annotation」. They carry no annotation: all three read undecidable to the detector, not dropped, so they hold 0 ledger rows and appear in no x-dropped-refinements. PR body §6 states the truth, so the two carriers disagree. Not a contract defect and not load-bearing for the arm — but a false sentence in a published changelog is not something this lane ships, so it goes back for a one-sentence correction before the gate clears.

N2 — NOTED, no action. objectstack.json (the aggregate $defs bundle) is a third mover in the tree, carrying the same two nodes. The body's "per-schema" phrasing excludes it by definition, so it is not a contradiction — but a reader running diff -rq sees 3, not 2.

N3 — NOTED, a precision point, no action. The emitter docblock's 「enum is specified as a non-empty array」 is exact for draft-04 and a SHOULD in draft 2019-09/2020-12, the dialect these files declare. The operative claim survives either way: Ajv2020().compile({propertyNames:{not:{enum:[]}}}) throws (enum must have non-empty array), so emitting it would take the published file down. The early return is reachable through emitProjectableRefinement's own signature (readonly string[]), so it is belt-and-braces rather than dead code.

N4 — a reading disagreement, recorded rather than resolved. This review measured pnpm --filter @objectstack/spec test as 496 files passed + 1 skipped (497); 14562 passed + 1 skipped (14563), where the delivering dev reported 497 / 14564 / 0 skipped. Shaped like an environment-conditional skip; the skipped file's identity is NOT MEASURED. The PR body will carry both readings rather than assert one.

NOT MEASURED, declared

Instrument reach

The "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 propertyNames/not implementation, documents at any other slot, and the four projections that bypass the choke point by design and carry no arm — scripts/build-react-blocks-contract.ts:75, src/automation/schemaless-node-config.zod.ts:505, src/automation/approval.zod.ts:967, src/data/driver/common.zod.ts:852 — none of which was measured here.

Implemented-by: claude/issue-18670-banned-keys-projection
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS

needs:contract-review stays hung on both carriers — ⛔ this is NOT a strip and NOT a failure. N1 sends one sentence back to the delivering dev, and the head will move when it lands. Clearing now would leave a clear that does not cover what would land, which is the C3 state this pair was already in once. The gate clears in one stroke on both carriers once the corrected head is in.


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>

Copy link
Copy Markdown
Collaborator Author

Gate cleared — needs:contract-review comes off BOTH carriers, in one stroke

Seat domain:spec#4. Card #18670 and PR #19137 both carried the gate; both removals are taken back-to-back, seconds apart. ⛔ A single removal would be a strip signature, and this is not one.

What the clear covers

The at-tier contract review returned PASS on head 384d27ac18 (record: comment 5737573936). Since that head the branch has moved once, to 184615ded95a9610055471de7c5214311158d787, and this seat read the diff rather than the report:

git diff --stat 384d27ac18 184615ded9
 .changeset/18670-project-banned-keys.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

One clause, in one prose sentence, in one changeset file. No source file, no emitter, no ledger, no test, no published artefact. The contract surface the review judged is byte-unchanged, so the PASS covers what would land and no re-review is owed. ⛔ This is the seat reading the diff; it is ⛔ not the seat re-taking the review, and ⛔ not a head-move waved through because it looked small.

The clause that moved — N1, discharged

Was: 「…so those sites stay unprojected and keep their annotation.」
Now: 「…so those sites stay unprojected — and because the detector reads them undecidable rather than dropped, they carry NO annotation and hold NO ledger row: published yet unratcheted.」

That is what the review measured: all three $-ban record nodes in data/NormalizedFilter.json read undecidable, hold 0 ledger rows, and appear in no x-dropped-refinements. The changeset ships to CHANGELOG.md, which is why a false clause in it was worth a round.

N4 — resolved, and the discriminator is named

The two test counts were both correct readings, of different trees, and the delivering dev found what separates them rather than arguing the numbers together:

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

@os-steve
os-steve marked this pull request as ready for review September 19, 2026 00:08
@os-steve
os-steve added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 5eebc9e Sep 19, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-18670-banned-keys-projection branch September 19, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants