Skip to content

feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it - #19335

Draft
os-bill wants to merge 5 commits into
mainfrom
claude/issue-18670-propertynames-not-pattern-arm
Draft

os-bill wants to merge 5 commits into
mainfrom
claude/issue-18670-propertynames-not-pattern-arm

Conversation

@os-bill

@os-bill os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Part of #18670 — item 2, the fifth arm the batch #193 ruling added to the closed projection list, plus that ruling's second acceptance item. This body carries no closing keyword for that number on purpose: 566 dropped refinement sites remain across 205 published schemas, and whether the card closes is the seat's call rather than this PR's.

Clause-②: yes

Carrier: the published artefact packages/spec/json-schema/data/NormalizedFilter.json. The published JSON Schema narrows toward what the runtime already refuses, and no document the runtime accepts becomes refused.

Director ruling 5749025303, batch #193 item 3, letter A, maintainer 「其他同意」 2026-09-20T09:44Z: 「A fifth arm joins the closed projection list: propertyNames: { not: { pattern } }, scoped to that one site and to the ^\$ ban, under the same one-ledger-row-at-a-time discipline as the four landed arms; the published keyword and the enforced predicate are built from a single source so they cannot name different things; an ablation proves the pin (the emitter removed ⇒ the rows return).」

Base f93beea0a6; head after merging origin/main (e3b3cdd2df) through scripts/pm/os-regen-merge.sh: 1dfe2f40bc.


1. The measurement that decided step 1 — and it came out YES

The ruling put one measurement before the arm: can those three NormalizedFilter.json nodes hold a ledger row at all? They read undecidable, and the thread's worry was that closing the rule would buy a narrower file with no testable row — the opposite trade from every arm landed so far.

⛔ It is not a grep question, and the card's own instruction says so: packages/spec/json-schema/** is 0 tracked files on origin/main (lit control, same instrument: packages/spec/src/data/ reads 167 tracked), because .gitignore:63 ignores it. Every reading below is against a tree generated by the repo's own toolingpnpm --filter @objectstack/spec build, whose first step is gen:schema (OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts).

The answer: a row CAN be held, and the reason it was not is a defect in the detector. The generator publishes NormalizedFilter through its THIRD projection attempt — projectByPruningUnionBranches, which drops the z.date() union branches and publishes the rest. The detector's projectOrNull stopped at the two strict rungs. So it was asking what a projection nobody publishes says, and answering undecidable:

node plain output rung plain input rung branch-pruning rung differential under it
lazy.$and.element.options[0] throws throws ok, 16772 bytes identical ⇒ dropped
lazy.$or.element.options[0] throws throws ok, 16772 bytes identical ⇒ dropped
lazy.$not.options[0] throws throws ok, 16772 bytes identical ⇒ dropped

⇒ the ruling's first branch applies: the detector judges those three nodes. The undecidable row shape was its fallback 「if a row cannot be held」, and that antecedent is false, so ⛔ no unread ledger field was added for an empty population. What the hole got instead is §2.

2. Second acceptance item — the blind spot, measured to zero and then pinned there

projectOrNull now carries the generator's third rung and reports which rung answered, so a differential can never compare a pruned projection with an unpruned one (nothing observed reaches that guard; it is written down so the day it stops holding reads undecidable and is counted, rather than reading projected and vanishing).

Repo-wide effect, from the generator's own census line:

published schemas dropped sites projected undecidable
base f93beea0a6 204 560 357 9
+ the ladder rung 205 569 357 0
+ the arm (this PR) 205 566 360 0

⚠️ The ledger GREW before it shrank, and the growth is the whole point of the item. Seven sites became countable that no ratchet could see — data/FieldOperators and data/NormalizedFilter each gained their $between pair, and data/RangeOperator entered the ledger at all, a published schema that had been holding zero entries. Then the arm deleted three. Net: 204 entries / 560 sites → 205 / 566.

And a published site that still cannot be adjudicated now fails the build by name, printing the paths and the two legitimate remedies (teach the ladder a rung the generator has; or take the decision to give the ledger an undecidable row shape). ⛔ The hole cannot reopen in silence.

3. The arm, and the single source

banned-key-pattern — 「no document may carry a key matching this pattern」 — emitted as propertyNames with a not over a pattern. A $-prefix ban is an open key set, so the existing banned-keys arm cannot express it: a finite list that merely sampled the set would be wider than the rule, which the closed list forbids by construction.

Single source, asserted rather than argued. bannedKeyPattern compiles its regular expression from the declared pattern string, so the keyword the file publishes and the rule the runtime enforces are one string read twice. A test reads the emitted pattern off the published artefact and the declaration off the predicate and compares them — an emitter that re-spelled the rule, or a declaration edited without its predicate, fails there rather than drifting.

Exact, not approximate. A JSON object's properties are exactly its own enumerable string-keyed ones, and propertyNames judges exactly those names. JSON Schema specifies pattern as an ECMA-262 regular expression evaluated as a SEARCH — unanchored, "does a match occur anywhere" — which is RegExp.prototype.test and nothing else. So ^\$ and the hand-written key.startsWith('$') it replaces name one set, pinned over a key corpus. It is presence and never value: a matching key present with a null value is present to both.

Scoped mechanically, which is how the ③ objection is answered. The standing objection to a regex-shaped arm is that its over-reach cannot be read off the declaration the way a key list's can. The bound is a second closed list: BannedKeyPattern is a union of the pattern strings this package publishes, exactly one today, so a call site cannot invent a regex — there is no plain string type to pass, and widening it is the same reviewed decision that adding an arm is. The compiler refuses the second pattern; it does not arrive by a call site's choice.

⛔ No flags on the regular expression, and that is part of the equality rather than a style choice: a JSON Schema pattern has none to carry, and the global flag would make test stateful through lastIndex, so a key's verdict would depend on which keys were judged before it. Pinned both ways.

⛔ The predicate reads OWN enumerable keys and never the in operator — pinned with a name planted on the prototype, where the two readings actually come apart.

4. The card's own class, before and after — measured with a real validator

ajv 8 (draft 2020-12) compiled against the generated data/NormalizedFilter.json on each side:

document ajv BEFORE ajv AFTER
{} true true
{"$and":[{"amount":{"$eq":1}}]} true true
{"$and":[]} true true
{"$and":[{"$and":[]}]} true true
{"$or":[{}]} true true
{"$not":{}} true true
{"$not":{"amount":{"$eq":1}}} true true
{"$and":[{"$bogus":{"$eq":1}}]} true false
{"$or":[{"$bogus":{"$eq":1}}]} true false
{"$not":{"$bogus":{"$eq":1}}} true false

The three that move are refused by the runtime, which names the rule: 「a field condition's keys are field names, never $-prefixed operators」. ⇒ the validator stops answering PASS on metadata the platform refuses, and nothing the runtime accepts became refused — the empty combinators and the nested group members are the direction that would have broken had the ban landed on the union instead of on the field-condition branch, and they are pinned.

All three published nodes now carry the rule, conjoined and never substituted (a record states propertyNames: { type: 'string' } of its own, and replacing it would trade a key-TYPE rule for a key-NAME rule — a narrowing bought with a widening):

{
  "type": "object",
  "propertyNames": { "type": "string" },
  "additionalProperties": { "...": "the operator map" },
  "allOf": [ { "propertyNames": { "not": { "pattern": "^\\$" } } } ]
}

5. Blast radius — the whole published tree

The six source files were reverted to the base, the generator re-run, and the two trees compared byte for byte. Revert leg proven on disk: each path's blob hash equalled its base blob before anything ran. Restore leg proven by bytes: git diff HEAD printed 0 bytes, git status --porcelain printed nothing, and each path's blob hash equalled its HEAD blob.

reading value
files common to both trees 1535
byte-identical 1530
moved 5

The five, by name: data/NormalizedFilter.json (gains the ban at three nodes; gains the two $between annotation rows the ladder made visible), data/FieldOperators.json and data/RangeOperator.json (annotation only — they gain x-dropped-refinements rows, and x- keywords are ignored by every validator, so the set of documents they accept is unchanged), objectstack.json (the bundle; its 29 differing leaf paths sit under exactly those three definitions and nowhere else), and .build-input-hash-schema.

openapi.json measured separately and with the right instrument. gen:schema never writes it, so comparing it inside the sweep above would have read two copies of the same stale file and reported a false identical. gen:openapi was run on both trees: sha256 34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa on both sides — this arm reaches no schema that surface publishes.

6. Ablation — the pin can fail, and the rows do return

scripts/ablation-replace.mjs replaced the one line dispatching the arm, with the mutation verified against the disk: anchor 1 → 0, marker 0 → 1, blob 4c5881bf5d1f92da85bc6406.

⭐ Resolution stated, because a false green here points the wrong way: every consumer reaches this module by a relative specifier, which resolves to source and never through the package exports to dist. There is no built artefact between the mutation and the verdict, so no dist preflight applies.

leg result
refinement-projection.test.ts exit 1 — 12 failed / 66 passed, the single-source pin and the live seam among them
gen:schema exit 1 — naming all three rows returning by name: lazy.$and.element.options[0], lazy.$not.options[0], lazy.$or.element.options[0]
restore blob back to 4c5881bf5d1f == HEAD, git diff HEAD 0 bytes, anchor back to 1 and marker back to 0

The second leg is the ruling's own requirement: 「the emitter removed ⇒ the rows return」. They do — and they exist to return only because §2 made those nodes countable first. Regenerated afterwards, data/NormalizedFilter.json came back to sha256 80041a0b…, byte-identical to the pre-ablation artefact.

7. Verification — real exit codes, each captured before any pipe

check exit
pnpm --filter @objectstack/spec build 0
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/spec test 0 — 500 test files / 14663 tests, all passed, dist built
pnpm --filter @objectstack/spec gen:schema 0 — ledger balanced
pnpm --filter @objectstack/spec gen:openapi 0 — byte-identical to base
pnpm --filter @objectstack/spec check:generated 0 — 16 of 16 generated artefacts up to date
pnpm lint 0 — the whole repository, eslint . --no-inline-config, not a narrowed subset
derived gate families, reconciled by scripts/pm/dispatch-gates.mjs --ran 86 derived / 82 exit 0 / 4 NOT MEASURED / 0 UNRUN

The four NOT MEASURED each exit 3PREREQUISITE NOT MET, a code that is explicitly neither pass nor failure — because each needs a whole-repo build closure that CI produces: check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. ⛔ Declared, not skipped.

api-surface-declarations/ moved, and the movement is order-only — but it IS mine. check:api-surface (the name-level gate) stays green with no diff at all. The declaration-text artefact did move, and rather than assume, it was tested: with this branch's six source files reverted to the base and the package rebuilt, check:api-surface-declarations exits 0 — so the movement belongs here. Characterised by bytes: 10 changed lines, 9 of them a whole-line multiset identity (two enum members swapping places), and the tenth a union whose quoted tokens are the same set, the same count, and whose text is identical once the tokens are masked. ⇒ no declaration added, removed, or changed in meaning. Regenerated and committed as its own commit.

8. Merge hygiene

origin/main was merged in through scripts/pm/os-regen-merge.sh — ⛔ never rebased, ⛔ never force-pushed. That path was taken because git check-attr merge reads os-regen on packages/spec/api-surface-declarations/api.txt and system.txt, per file rather than by counting .gitattributes rows. After the merge the implementation body was re-asserted by name (bannedKeyPattern, OPERATOR_PREFIX_KEY_PATTERN, BannedKeyPattern, emitBannedKeyPattern, conjoinPropertyNames, undecidableEntries), the whole chain was regenerated, and check:generated reported 16 of 16 current with no regeneration diff.

Acceptance notes

  • ⚠️ A dispatch instruction that the repository contradicts, named rather than quietly resolved. The dispatch said to regenerate packages/spec/dropped-refinements.baseline.json 「with the repo's tooling; never hand-edit it」. There is no such tooling: the ledger has no gen: script by design, build-schemas.ts calls it 「a committed, hand-edited ledger」 in its own refusal text, and the module docblock argues the point at length — a generator would let a new gap be admitted by running a command instead of by a decision. The operative half of the ruling — 「⛔ do not serialise on it」 — was followed: this PR did not wait on spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147. Every ledger edit here is the corrected entry the gate itself printed, pasted verbatim, which is the closest thing to tooling the artefact has.
  • Noted, not filed — the sibling changeset in this same release now contradicts the tree. .changeset/18670-project-banned-keys.md records that the $-prefix sites 「stay unprojected … carry NO annotation and hold NO ledger row: published yet unratcheted」. True of its own tree, false of this one. ⛔ Not rewritten — a landed record of what that PR shipped — so this PR's changeset states the supersession instead, and the two read coherently as one CHANGELOG. Carrier: none needed; both entries publish together.
  • Noted, not filed — and this PR IS the carrier the previous one named. feat(spec)!: publish the banned-keys rule the tracing filter arm enforces #19137 named 「the next PR that edits packages/spec/scripts/build-schemas.ts」 as carrier for a stale mention of the retired api-surface-signatures.json. This PR does edit that file, so it inherits the hand-off, and it is being declined deliberately: the line is a documentation nit in a comment, not one of the three filing classes, and it is not this ruling's defect class. It survives at packages/spec/scripts/build-schemas.ts:874. Carrier: the next PR that edits that file for a reason of its own.
  • dropped-refinements.baseline.json is a shared hot file held by spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147. Not serialised on, per the ruling; collisions resolve by regenerating through scripts/pm/os-regen-merge.sh, ⛔ never by hand-editing conflict markers.
  • The arm list's own roster pin and the new pattern-set pin are both asserted as exact equalities, so a sixth arm — or a second pattern — updates a reviewed line in a diff rather than widening the narrowing quietly.

Generated by Claude Code

…ator does, and a published site it cannot adjudicate now fails the build

The detector decided `dropped` vs `projected` on a two-rung ladder — output,
then input — while `build-schemas.ts` publishes on a three-rung one: a node
whose every io direction refuses over an unrepresentable member still reaches
its file when that member sits in a union position, because the emit loop drops
the branch and publishes the rest.

So nine PUBLISHED sites read `undecidable`: the comparison had no two sides, the
verdict the ledger does not count. Three of them are the `$`-prefix ban on a
normalized field condition, which published as a bare object and held zero
ledger rows — no repair of it could ever have deleted one.

`projectOrNull` now carries the generator's third rung, and reports WHICH rung
answered so a differential can never compare a pruned projection with an
unpruned one. Measured: 9 undecidable -> 0, and the nine become ordinary
`dropped` rows (+9 sites, +1 schema: data/RangeOperator was published holding
no ledger entry at all).

A published site that still cannot be adjudicated now fails the build by name,
so the ratchet's own blind spot cannot reopen in silence.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
Fifth arm of the closed projection list: `banned-key-pattern`, emitted as
`propertyNames: { not: { pattern } }` — the spelling JSON Schema has for a rule
about the SHAPE of a key name, where `banned-keys` has one about a finite list.

Scoped to one site and one pattern. `BannedKeyPattern` is a closed union of the
patterns this repository publishes, exactly one today, so a call site cannot
invent a regex: there is no `string` to pass, and widening it is the same
reviewed decision that adding an arm is. That is the bound on the objection a
regex-shaped arm has to answer — over-reach a reader cannot see in the
declaration is held down by how few declarations exist.

Single source: `bannedKeyPattern` compiles its `RegExp` FROM the declared
pattern string, so the keyword the file publishes and the rule the runtime
enforces are one string read twice and cannot come to mean different things.
Flagless, deliberately — a JSON Schema `pattern` has no flags to carry, and `g`
would make `test` stateful through `lastIndex`.

`data/NormalizedFilter.json`'s three field-condition record nodes now state the
ban. Measured with ajv 8 on the generated file: the specimen the runtime
refuses is refused at all three nodes, and every document the runtime accepts —
the empty combinators and group members included — is still accepted.

Ledger: 3 rows deleted, 0 added. Census 566 dropped / 205 schemas / 360
projected (3 banned-key-pattern) / 0 undecidable.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
… made its rows exist

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
The new import edge into `src/data/filter.zod.ts` moves the order TypeScript
emits some type-literal members in. Measured: reverting this branch's six
source files to the merge base makes `check:api-surface-declarations` green
again, so the movement belongs to this change and not to `main`.

Characterised rather than waved at: 10 changed lines, 9 of them a whole-line
multiset identity (two `z.ZodEnum` members swapping places) and the tenth a
union whose quoted tokens are the same set, the same count, and the same text
once the tokens are masked. ⇒ no declaration added, removed or changed in
meaning, and `check:api-surface` — the name-level gate — stays green with no
diff at all.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/api.txt, packages/spec/api-surface-declarations/system.txt, 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
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/api.txt, packages/spec/api-surface-declarations/system.txt, 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 81e12e186f3fa3558df9077a338d9df6effaaadfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d95d2105bac11a2f179dc328a35d04d7bc118cea — the merge of head 1dfe2f40bce77270758d9b31b01dd8d46875a290 into base 81e12e186f3fa3558df9077a338d9df6effaaadf, 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 d95d2105bac11a2f179dc328a35d04d7bc118cea && git checkout d95d2105bac11a2f179dc328a35d04d7bc118cea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 81e12e186f3fa3558df9077a338d9df6effaaadf 1dfe2f40bce77270758d9b31b01dd8d46875a290 && git checkout -B drift-repro 81e12e186f3fa3558df9077a338d9df6effaaadf && git merge --no-ff 1dfe2f40bce77270758d9b31b01dd8d46875a290

node scripts/docs-audit/affected-docs.mjs --json 81e12e186f3fa3558df9077a338d9df6effaaadf

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

os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1dfe2f40bce77270758d9b31b01dd8d46875a290

Independent contract review of PR #19335 against card #18670 and the maintainer ruling 5749025303 (batch #193 item 3, letter A). Reading time 2026-09-20T12:14Z. Every number below was re-taken in this session's own worktrees — head 1dfe2f40bc (merge-base with origin/main = e3b3cdd2df), control at the merge-base e3b3cdd2df, and a second control at origin/main 81e12e186f — never from the PR body, the dev report or any seat comment. packages/spec/json-schema/** is untracked (git ls-files packages/spec/json-schema = 0 files, .gitignore:63; lit control on the same instrument: git ls-files packages/spec/src/data = 167 files), so every published-tree reading here is against a tree generated by pnpm --filter @objectstack/spec build (head, exit 0) or pnpm --filter @objectstack/spec gen:schema && gen:openapi (both controls, exit 0).

① Derived judgments

Executed the ruling, and only that — YES.

  • Ordering (step 1 before the arm) — honoured in the history, not only claimed. Commit 40727ab0e7 (2026-09-20T10:28:19Z) touches only scripts/build-schemas.ts, scripts/lib/dropped-refinements.ts and the ledger, and adds the three nodes lazy.$and.element.options[0] / lazy.$or.element.options[0] / lazy.$not.options[0] to data/NormalizedFilter as ordinary dropped rows — that IS the step-1 answer (a row can be held). Commit 132e3e677e (2026-09-20T10:34:53Z) then lands the arm and deletes those three rows. Neither commit touches the other's surfaces. Nit, not a defect: the detector commit's own measured block still reads 204/560/357/9 (only the head's reads 205/566/360/0); the generator does not gate that block, and the head ledger is what lands.
  • The arm is the one prescribed. emitBannedKeyPattern writes propertyNames: { not: { pattern } } through the shared conjoinPropertyNames, which keeps the record's own propertyNames: { type: 'string' } and appends the ban under allOf — verified on the generated head file at all three nodes (type: object, propertyNames: {type: string}, allOf: [{propertyNames: {not: {pattern: "^\$"}}}], the pattern being caret, backslash, dollar).
  • Single source — verified on the code and by a compile-time probe. bannedKeyPattern(keyPattern) builds its RegExp from the very keyPattern it stores in the declaration; the emitter reads declared.keyPattern; declare() is module-private so no other constructor of a banned-key-pattern declaration exists (grep: the only non-test call site is src/data/filter.zod.ts:1925; lit control bannedKeys( hits src/system/tracing.zod.ts). A call site cannot introduce a pattern the predicate does not enforce because there is no path from a string to the emitter: tsc --strict on a probe file refuses bannedKeyPattern('^x') (TS2345), a hand-built declaration with '\$' (TS2322) and a plain string variable (TS2345), while the sanctioned bannedKeyPattern(OPERATOR_PREFIX_KEY_PATTERN) compiles. Head typecheck exit 0.
  • Scope mechanically closed. BannedKeyPattern is the literal type of one exported constant; the test suite pins that constant to ^\$ and the arm roster to exactly five names; the generator census at head prints 3 banned-key-pattern projected sites and the ledger diff is those three deletions. No doc, skill or script outside packages/spec restates the arm roster (repo grep for dependent-required / banned-keys / PROJECTABLE_REFINEMENT_PATTERNS outside spec source/tests: 0 files; lit control: the PR's changeset, 3 hits).
  • Second acceptance item — the 9 → 0 premise verified, not inherited. build-schemas.ts reaches the file through three rungs (projectPublishedJsonSchema output → input → projectByPruningUnionBranches, lines ~512–533); the detector's projectOrNull at the merge-base stopped at two. Re-taken census: merge-base e3b3cdd2df and origin/main 81e12e186f both print 560 dropped / 204 schemas / 357 projected / 9 had no JSON form; head prints 566 / 205 / 360 / 0 published site(s) could not be adjudicated. The three NormalizedFilter record nodes went undecidable → dropped (detector commit) → projected (arm commit), which is what makes the arm's ledger deletions real. The new rung-mismatch guard and the build-failing undecidable check are present and the test file carries a lit control (probe/NeverProjects still reads undecidable).
  • Ablation — re-run here, not adopted. Lit control first: the projection test file on the unmutated head passes 78/78 (exit 0). Then, through the repo's own scripts/ablation-replace.mjs, the one dispatch line emitBannedKeyPattern(jsonSchema, declared.keyPattern); was replaced in scripts/lib/refinement-projection.ts (anchor 1 → 0, blob 4c5881bf5d1f26f96de45399, verified on disk): gen:schema exits 1 naming exactly lazy.$and.element.options[0], lazy.$not.options[0], lazy.$or.element.options[0] as returning gaps, and the test file exits 1 with 12 failed / 66 passed (the single-source pin, the three-node live seam and the ledger-row pin among them). Restore proven: blob back to 4c5881bf5d1f == HEAD, git diff HEAD empty, porcelain clean; the head tree regenerated afterwards is byte-identical to the pre-ablation data/NormalizedFilter.json (see gates line). The rows return only because the detector's third rung made them countable — the ruling's own requirement, observed rather than inherited.
  • Pin discrimination on over-reach — a second ablation of my own. The ruling's ablation proves absence; it does not prove the pin refuses a WIDER pattern, which is the objection to a regex arm. So I also mutated the source string from ^\$ to \$ (unanchored, which would ban a$b and x$) and re-ran the test file: exit 1, 3 failed / 75 passed — the closed-pattern-set pin (disagreement on a$b), the declaration pin, and the three-node live-seam shape pin. Restored, blob 6c3158d271 == HEAD, porcelain clean. The pins are not vacuous in either direction.

② Semver level

Clause-②: yes is right, and (narrowing) is the right arm. Nothing was added to any accept set; the published data/NormalizedFilter.json (and its $defs copy in objectstack.json) now refuses documents it accepted, so the published artefact narrows. Changeset .changeset/18670-project-operator-key-pattern.md: @objectstack/spec: minor with a BREAKING banner (launch-window convention), Clause-②: yes (narrowing), ADR-0087 marker not-required (no-migration-prescription)node scripts/check-adr-0087-registration.mjs exit 0, check-changeset-no-major.mjs exit 0, check-empty-changeset.mjs exit 0. The pair predicate check-clause2-carriers.mjs --pair 19335 reads the governing claim as 5749165780 naming this branch, Clause-②: yes on both card and PR.

Migration text: the changeset names the refused specimen ({"$and":[{"$bogus":{"$eq":1}}]}), the node, and the rule (a field condition's keys are field names such as amount / account.name, never $-prefixed operators). An author hitting the new validator refusal was already hitting the runtime refusal with the same sentence, and the fix is stated by the rule (put the operator under a field key). I would have liked one explicit "to fix:" line, but nothing an author can write is removed or re-spelled, so the FROM → TO obligation does not strictly apply; noted, not failed.

③ Boundary flags

  • Blast radius, re-taken on the merge-base control (the right control — origin/main carries 4 spec zod changes the head does not, which contaminate a main-vs-head diff with unrelated .describe() movement): 1535 files common, 1530 byte-identical, 5 moved, by name .build-input-hash-schema, data/FieldOperators.json, data/NormalizedFilter.json, data/RangeOperator.json, objectstack.json. With every x-* key stripped, the ONLY semantic change in the whole tree is the three propertyNames/not/pattern leaves under data/NormalizedFilter (file and bundle); FieldOperators and RangeOperator are annotation-only. openapi.json sha256 34b1dc9c2cf1… on merge-base, origin/main and head alike.
  • ajv leg, re-taken (ajv 8.20.0 from the repo's own store, draft 2020-12, strict: false) on a 24-document corpus, against the runtime NormalizedFilterSchema.safeParse on the same corpus (runtime verdicts identical at head and origin/main, 24/24): the PR's three specimens go true → false; two further $-prefixed probes of mine — {"$and":[{"amount":{"$eq":1},"$x":{"$eq":2}}]} and {"$and":[{"$eq":{"$eq":1}}]} — also go true → false and are runtime-refused, so the narrowing is exactly the ^\$ class and not wider. Every runtime-accepted document stays true on both sides, including the boundary shapes a wider pattern would have caught: a$b, x$, the empty key "", account.name, nested groups {"$not":{"$not":{}}} / {"$not":{"$and":[…]}} / {"$or":[{"$or":[…]}]}, $between, and every empty combinator. Zero runtime-accepted documents became refused.
  • Not this PR's, pre-existing and unmoved, named so nobody re-derives it: {"$and":[{"amount":{"$bogusop":1}}]} is runtime-accepted (non-strict FieldOperatorsSchema strips the unknown operator) and ajv-refused on merge-base, main and head alike (additionalProperties: false on the published operator map). That is the opposite direction from this card's class and is byte-identical across the diff.
  • api-surface-declarations movement: order-only (enum member swaps optional/required and one union re-ordering, same token sets). At head check:api-surface-declarations exit 0 and check:api-surface exit 0; at the merge-base with its own build build exit 0, check:api-surface-declarations exit 0 ("declaration text unchanged, 17 entry points, 5364 declarations"), check:api-surface exit 0 — so the committed artefact is consistent with its own source on both sides and the movement is attributable to this diff, as the PR says.
  • Gates re-run at head, real exit codes: build 0 · gen:schema (inside build) 0 with ledger balanced · check:generated 0 · check:docs 0 · check:api-surface 0 · check:api-surface-declarations 0 · typecheck 0 · test 0 (500 files / 14663 tests passed) · root pnpm lint NOT MEASURED — whole-repo eslint was still running in my worktree when the coordinator asked for the record; CI reports Lint & Repo Gates on this head, which I did not re-derive · changeset gates 0/0/0 (above). NOT MEASURED: scripts/pm/dispatch-gates.mjs --ran derived families and the four whole-repo-closure gates the PR lists — no repo-wide build was made in this worktree.
  • No governed surface in the file list (10 files: .changeset/, packages/spec/** only). Draft, needs:contract-review present. No label or body was touched by this review.

Independence pair —
(a) Measured first-hand: the untracked-tree fact; commit order and per-commit file sets; the compile-time closure probe; the ajv leg on three generated trees plus the runtime corpus; the whole-tree comparison and semantic diff at merge-base and at origin/main; the openapi hashes; the census lines on three trees; both ablations; every exit code listed above.
(b) Taken from someone else's reading and NOT verified: the PR's dispatch-gates.mjs --ran tally (86/82/4/0); CI's own green on the head (only observed as check-run status, not re-derived); the PR's claim that its merge went through os-regen-merge.sh.

Implemented-by: claude/issue-18670-propertynames-not-pattern-arm
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

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:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants