Conversation
…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>
…opertynames-not-pattern-arm
📓 Docs Drift Check6 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 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 |
Contract reviewServed-tier: Independent contract review of PR #19335 against card #18670 and the maintainer ruling ① Derived judgmentsExecuted the ruling, and only that — YES.
② Semver level
Migration text: the changeset names the refused specimen ( ③ Boundary flags
Independence pair — Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ PARKED — 本 head 落不了地,且挡住它的不是本 PR。 卡 #18670 已转
pm:blocked,门禁卡是 #19240(认领读者claimRetractions只认同一 login 的Release:,SKILL.md:496 的死认领回收写不进它)。本 PR 的落地前置 ① 与 ③ 成立(达档## Contract review记录5749728565在 head1dfe2f40bc上;checks 全绿);② 不成立:check-clause2-carriers.mjs --pair 19335= exit 4,唯一 ✗ 行是 C9(本卡线程上两条他席认领仍 LIVE)。完整读数、对照与本席自纠见 #18670 评论5752999363。⛔ 保持 draft,⛔ 不挂 auto-merge。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 mergingorigin/main(e3b3cdd2df) throughscripts/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.jsonnodes hold a ledger row at all? They readundecidable, 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 onorigin/main(lit control, same instrument:packages/spec/src/data/reads 167 tracked), because.gitignore:63ignores it. Every reading below is against a tree generated by the repo's own tooling —pnpm --filter @objectstack/spec build, whose first step isgen: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
NormalizedFilterthrough its THIRD projection attempt —projectByPruningUnionBranches, which drops thez.date()union branches and publishes the rest. The detector'sprojectOrNullstopped at the two strict rungs. So it was asking what a projection nobody publishes says, and answeringundecidable:lazy.$and.element.options[0]droppedlazy.$or.element.options[0]droppedlazy.$not.options[0]dropped⇒ the ruling's first branch applies: the detector judges those three nodes. The
undecidablerow 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
projectOrNullnow 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 readsundecidableand is counted, rather than readingprojectedand vanishing).Repo-wide effect, from the generator's own census line:
f93beea0a6data/FieldOperatorsanddata/NormalizedFiltereach gained their$betweenpair, anddata/RangeOperatorentered 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
undecidablerow 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 aspropertyNameswith anotover apattern. A$-prefix ban is an open key set, so the existingbanned-keysarm 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.
bannedKeyPatterncompiles 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 emittedpatternoff 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
propertyNamesjudges exactly those names. JSON Schema specifiespatternas an ECMA-262 regular expression evaluated as a SEARCH — unanchored, "does a match occur anywhere" — which isRegExp.prototype.testand nothing else. So^\$and the hand-writtenkey.startsWith('$')it replaces name one set, pinned over a key corpus. It is presence and never value: a matching key present with anullvalue 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:
BannedKeyPatternis 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
patternhas none to carry, and the global flag would maketeststateful throughlastIndex, 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
inoperator — 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.jsonon each side:{}{"$and":[{"amount":{"$eq":1}}]}{"$and":[]}{"$and":[{"$and":[]}]}{"$or":[{}]}{"$not":{}}{"$not":{"amount":{"$eq":1}}}{"$and":[{"$bogus":{"$eq":1}}]}{"$or":[{"$bogus":{"$eq":1}}]}{"$not":{"$bogus":{"$eq":1}}}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 HEADprinted 0 bytes,git status --porcelainprinted nothing, and each path's blob hash equalled its HEAD blob.The five, by name:
data/NormalizedFilter.json(gains the ban at three nodes; gains the two$betweenannotation rows the ladder made visible),data/FieldOperators.jsonanddata/RangeOperator.json(annotation only — they gainx-dropped-refinementsrows, andx-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.jsonmeasured separately and with the right instrument.gen:schemanever 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:openapiwas run on both trees: sha25634b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaaon both sides — this arm reaches no schema that surface publishes.6. Ablation — the pin can fail, and the rows do return
scripts/ablation-replace.mjsreplaced the one line dispatching the arm, with the mutation verified against the disk: anchor 1 → 0, marker 0 → 1, blob4c5881bf5d1f→92da85bc6406.⭐ 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
exportstodist. There is no built artefact between the mutation and the verdict, so no dist preflight applies.refinement-projection.test.tsgen:schemalazy.$and.element.options[0],lazy.$not.options[0],lazy.$or.element.options[0]4c5881bf5d1f== HEAD,git diff HEAD0 bytes, anchor back to 1 and marker back to 0The 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.jsoncame back to sha25680041a0b…, byte-identical to the pre-ablation artefact.7. Verification — real exit codes, each captured before any pipe
pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec gen:schemapnpm --filter @objectstack/spec gen:openapipnpm --filter @objectstack/spec check:generatedpnpm linteslint . --no-inline-config, not a narrowed subsetscripts/pm/dispatch-gates.mjs --ranThe four NOT MEASURED each exit 3 —
PREREQUISITE 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-declarationsexits 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/mainwas merged in throughscripts/pm/os-regen-merge.sh— ⛔ never rebased, ⛔ never force-pushed. That path was taken becausegit check-attr mergereadsos-regenonpackages/spec/api-surface-declarations/api.txtandsystem.txt, per file rather than by counting.gitattributesrows. 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, andcheck:generatedreported 16 of 16 current with no regeneration diff.Acceptance notes
packages/spec/dropped-refinements.baseline.json「with the repo's tooling; never hand-edit it」. There is no such tooling: the ledger has nogen:script by design,build-schemas.tscalls 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..changeset/18670-project-banned-keys.mdrecords 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.packages/spec/scripts/build-schemas.ts」 as carrier for a stale mention of the retiredapi-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 atpackages/spec/scripts/build-schemas.ts:874. Carrier: the next PR that edits that file for a reason of its own.dropped-refinements.baseline.jsonis 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 throughscripts/pm/os-regen-merge.sh, ⛔ never by hand-editing conflict markers.Generated by Claude Code
Generated by Claude Code