Skip to content

feat(spec)!: publish the two named refinement patterns the runtime already enforces - #18952

Merged
os-elon-musk merged 8 commits into
mainfrom
claude/issue-18670-project-expressible-refinements
Sep 18, 2026
Merged

os-elon-musk merged 8 commits into
mainfrom
claude/issue-18670-project-expressible-refinements

Conversation

@os-elon-musk

Copy link
Copy Markdown
Collaborator

Part of #18670 (item 2 only — see "What is left" below; item 1 landed as #18729).

Clause-②: yes (narrowing)

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

What this does

z.toJSONSchema() has no arm for a custom check. On zod 4.4.3 — the version packages/spec resolves — a plain record, the same record with a .refine(), and the same record with an aborting .refine() project byte-identically. So every rule written as a refinement reached the runtime and not packages/spec/json-schema/**: the published file was wider than the contract it is generated from, which is the direction where an author's (or an AI's) validator answers PASS right up to the moment the platform answers NO.

Two of the ruling's four named patterns now project, and only those two:

pattern emitted as sites why it is EXACT, not approximate
required-one-of anyOf of one required per key, conjoined through allOf 137 A key absent from a JSON object is the only way for its value to read undefined, so required and !== undefined name the same set of documents. A key present with any JSON value, null included, satisfies both.
non-blank-string minLength: 1 plus the pattern \S 60 String.prototype.trim removes exactly ECMA-262 WhiteSpace ∪ LineTerminator, and \S is the complement of that same set.

system/TraceSamplingConfig.json — the card's own named specimen — now carries no x-dropped-refinements at all, and shared/Expression.json states the source-or-ast rule, so { "dialect": "cel" } is refused by the published file exactly as the runtime already refused it.

Proof of work: the shrink-only ledger

packages/spec/dropped-refinements.baseline.json, measured by the generator itself:

reading before after
publishedSchemasWithDroppedRefinements 246 201
droppedRefinementSites 750 553
refinementSitesThatDidProject 0 197
refinementSitesWithNoJsonFormToCompare 3 3

45 rows deleted outright, 75 rows shrunk, 197 sites closed, 0 sites added anywhere. The edit was not typed by hand: a throwaway auditor parsed the gate's own "corrected entries, in full" output and refused to write unless the file round-tripped byte-identically through JSON.stringify(obj, null, 2), every removed site was one the post-change census classifies projected, no entry gained a site, and the per-entry arithmetic closed. Independently re-proved at JSON level against git show HEAD:...: 0 keys added, 45 keys deleted, 0 sites added, 197 sites removed, header equals body.

The generator also prints the closed population per pattern on every run, with a line of its own for a site that projects with no declared pattern — that bucket reads 0:

🔇 553 refinement site(s) across 201 published schema(s) reach the RUNTIME and not the published JSON Schema
     Also measured this run: 197 refinement site(s) DID reach the file, 3 had no JSON form on either side to compare.
📣 197 refinement site(s) DO reach the published JSON Schema, by declared pattern:
      137  required-one-of
       60  non-blank-string

The contract: nothing the runtime accepts becomes refused, MEASURED

The ruling is explicit that this is a correction of the machine-readable declaration and ⛔ not a behaviour change. That is a reading here, not an assertion. A probe parses one shared corpus of 6027 documents across 12 schemas — ExpressionSchema, EvaluatedExpressionSchema, the four input unions, PredicateSchema / PredicateInputSchema, UpdateAiConversationRequestSchema, and three deep composers (FlowSchema.edges[].condition, ObjectSchema.titleFormat, CronScheduleSchema.expression) — recording per document the success bit and every issue as a sorted code@path. Run in two worktrees, at the merge base and at this head, over the same corpus file:

merge-base 46559f61c   cases=12 documents=6027 accepted=1873 refused=4154
this head   eae168e20   cases=12 documents=6027 accepted=1873 refused=4154
cmp exit 0 · sha256 15a714e471f1ba43ec0c0c8773ac345c5c0d03974855ed15116ae2014119008a (both files)

Byte-identical, so the accept set, the refusal set and every refusal message and path are unchanged.

⭐ And the probe can fire. Under a lit control that weakens NON_BLANK_STRING from source.trim().length > 0 to source.length > 0 — one token — 732 of the 6027 documents move. So the zero above is a measurement, not a vacuous pass.

The two packages/spec/src/** files, and why each had to move

The projection cannot READ a predicate's meaning: .superRefine() and .check() carry no readable function at all (their check def holds only { check: 'custom' }, where .refine()'s holds { type, check, fn }), and a projection turning on fn.toString() would be a source-text parser. So the pattern is declared at the refinement's own call site, and requiredOneOf builds its predicate from that declaration, so the published anyOf and the enforced rule cannot name different keys.

Each change replaces the predicate expression handed to an existing .refine() and nothing else — no schema shape, no key, no message, no .strict(), no optionality, nothing added or removed:

  • src/shared/expression.zod.ts — e => e.source !== undefined || e.ast !== undefined becomes requiredOneOf(['source', 'ast']); three copies of (source) => source.trim().length > 0 become the shared NON_BLANK_STRING (one is inside typedExpressionStringArm, so it covers both the cron and the template slot).
  • src/api/protocol.zod.ts — p => p.title !== undefined || p.metadata !== undefined becomes requiredOneOf(['title', 'metadata']), plus its import.

The parse-equivalence reading above is the evidence that both substitutions are behaviour-preserving.

⚠️ One place the spelling differs from the ruling's prose, and why

The ruling names 「anyOf + required for required-one-of」. Emitted as a top-level anyOf beside the node's own type: object and properties, that is valid JSON Schema and correct to a validator — and it degraded the reference pages. scripts/lib/format-type.ts tests anyOf before properties, so the node stopped rendering as its object shape: content/docs/references/system/tracing.mdx's condition cell went from

Record[string, any] | string | { dialect: Enum[...]; source?: string; ast?: any; meta?: object }

(angle brackets written as square ones throughout this paragraph — the platform's body sanitizer eats a short angle-bracket fragment even inside a fence, so the real cell reads with the usual generic spelling)

to Record[string, any] | string | any | any, and 26 reference pages moved the same way — 182 insertions / 182 deletions. Those pages are the ADR-0033 authoritative input for AI authors, so that would be a second machine-readable lie traded for the first one. The same anyOf + required is therefore conjoined through allOf: identical to a validator, and gen:docs then produces a zero-line diff. Both the measurement and the absence are pinned (⛔ never writes a TOP-LEVEL anyOf). The PM accepted this reading as more faithful to the ruling than its literal nesting; ⛔ teaching format-type.ts to skip pure-required branches was declined by name — a shared renderer is the wrong blast radius for an equivalent emission.

Ablations — three, each restored with proof

Every leg ran through scripts/ablation-replace.mjs, so the mutation is proved on disk (anchor count and blob hash) and the restore by blob equality with HEAD plus an empty git diff HEAD.

mutation expected observed
required-one-of emits nothing the ledger's row deletions must fail check:authorable-surface exit 1 — 45 undeclared + 75 miscounted, exactly the rows this PR deleted and shrank
the detector's differential drops the generator's override the ledger can no longer see a closed site same 45 + 75 red: the coupling is load-bearing, not decoration
NON_BLANK_PATTERN corrupted to . the equivalence pin must fail 3 cases red, including every ECMA-262 blank code point

The second leg is the one that matters for the ruling's mechanism: without it, a site whose rule the file already states would read dropped for ever, and 「every site it closes deletes its ledger row」 would be unreachable.

What is left, and why this is Part of rather than a closing keyword

Two of the ruling's four named patterns are not taken here, and the census says why rather than leaving it to judgement:

  • banned keys (propertyNames / not) — zero clean candidates. The nearest sites judge a banned value on a string, or an allowed key set that is data-dependent (ai.paramHints against the action's own params), which is not mechanically derivable.
  • dependentRequired — exactly one candidate, 2 sites: data/SSLConfig's hasCert === hasKey, which is precisely dependentRequired: { cert: ['key'], key: ['cert'] }. Sound and small; deliberately not taken in this round so the verification surface stays two arms wide, per the dispatch's 「landing one or two patterns with the ledger shrinking measurably beats four half-done ones」.

So this request does not carry a closing keyword for the card: the remaining named-arm worklist above is a real remainder, and whoever takes it starts from these two measurements rather than a fresh census. The 553 sites still in the ledger are the ruling's intended terminal state for refinements outside the closed list — they stay dropped and annotated.

Verification

  • pnpm --filter @objectstack/spec build · check:generated — "All 15 generated artifacts are up to date".
  • pnpm --filter @objectstack/spec test — 489 files / 14209 tests, green. typecheck green (tsc --noEmit + check:scripts-typecheck + check:test-typecheck: 54 files / 259 errors / 144 pinned signatures, the standing ledger unchanged).
  • pnpm --filter @objectstack/spec exec vitest run --project repo over the five generator-facing files (build-schemas-check-mode, check-generated-ledger, schema-tree-freshness, dist-freshness, def-key-collisions) — 5 files / 141 tests, green.
  • npx eslint . --no-inline-config --format json — the whole repo, no narrowing: 6858 files, 0 errors, 0 warnings, taken at eae168e20.
  • Gate families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands from the merge base and re-run in full on this head: 84 derived, 79 exit 0, 5 NOT MEASURED, 0 UNRUN under --ran. The five: check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt each answer PREREQUISITE NOT MET (exit 3 — they sweep the built output of the whole workspace, which is CI's Build Core job), and check:pm-dispatch-gates was killed by the container's foreground cap at both 200s and 560s without reaching a verdict of its own. ⛔ None of the five reads as a pass.
  • origin/main merged three times through scripts/pm/os-regen-merge.sh, regeneration committed after each merge. The branch delta against origin/main is exactly the 11 paths of this change; control-flow.zod.ts, check-duration-unit-keys.ts, check-widening-tells.mjs and check-adr-0087-registration.mjs all diff empty against main, so the merges took main's content intact.

Acceptance notes

⛔ Observations only — nothing below is addressed here, and none of them is filed.

  • The x-dropped-refinements annotation still does not reach content/docs/references/**: the docs renderer drops unknown x- keys, so a reference page states a narrowed rule only where it became real JSON Schema keywords. Carrying the annotation onto the page is a docs-surface change. Successor: whoever takes the remaining named arms.
  • packages/spec/json-schema/** is gitignored and untracked while being shipped through files[], so the narrowing is visible in a review only through the ledger, the reference pages and the generator log — never as a diff of the artefact itself.
  • Four other z.toJSONSchema() callers inside packages/spec/src/** (approval node config, schemaless node config, driver common, metadata-type schemas) serve Studio's SchemaForm at runtime and were deliberately left alone: narrowing those would change what a form refuses, which is the behaviour change the ruling forbids. The override is exported so they can adopt it under a decision of their own.
  • The five sandbox builders in build-schemas-check-mode.test.ts still mount the committed package-root ledgers from a hand-kept list; nothing holds that list equal to the set the generator actually reads. Carried over from item 1, unchanged here.

维护者速读(草稿)

改了什么 —— 已发布的 packages/spec/json-schema/** 过去对「写成 .refine() 的规则」一字不提:作者或 AI 拿这些文件校验 metadata,校验通过,平台随后拒收。本 PR 让两条规则真正出现在文件里:「source 与 ast 至少有一个」和「字符串去空白后非空」。共 197 个站点从「运行时有、文件没有」变成「两边都有」,只降不升的台账从 246 个 schema / 750 个站点缩到 201 / 553。卡片点名的样本 TraceSamplingConfig 现在一条缺口都不剩。

为什么改 —— 裁决(批次 #154 第 3 项 C,维护者「同意」)把方向定死:只在规则是完整且可机械推导的命名模式时收窄,其余保持注解。这不是行为变化,而是把一份机器可读的声明改成它一直描述的那个运行时。

风险与代价(含回滚) —— 风险集中在一处:收窄已发布产物,理论上可能让昨天通过的文档今天不通过。这一点是实测排除的,不是论证排除的:6027 份文档、12 个 schema,在合并基与本分支上跑出逐字节相同的接受/拒收结果与错误码;并用一条对照变异证明这个探针能发现差异(732 份文档会移动)。代价是新增两个模块与一套等价性 pin。回滚成本低:两个新模块与 5 处调用点的替换是可逆的,台账回退到 246/750 即恢复原状;但回滚会让文件重新对机器撒谎。

席位意见 ——

你要做的 —— ① 确认「已发布 JSON Schema 可以向运行时方向收窄」这件事按裁决执行无误(裁决已定,此处仅复核落地与裁决一致)。② 决定剩下两条命名模式的去向:banned keys 实测零个干净候选,dependentRequired 只有 data/SSLConfig 一个 2 站点候选 —— 是另起一单,还是就此收尾。③ 本 PR 未携带关卡关键词,卡片的开闭由你或 PM 决定。


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/src/api/protocol.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/data-engine.mdx (via DECLARED (symbol, a top-level const object))
  • content/docs/kernel/services-checklist.mdx (via DECLARED (symbol, a top-level const object))
  • content/docs/protocol/objectql/query-syntax.mdx (via DECLARED (symbol, a top-level const object))
  • content/docs/ui/create-vs-edit-form.mdx (via DECLARED (symbol, a top-level const object))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json, packages/spec/src/api/protocol.zod.ts) — 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.

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 16cb493d5640f4e9bf762a41ac016d0c6113c92f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 16cb493d5640f4e9bf762a41ac016d0c6113c92f

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 16cb493d5640f4e9bf762a41ac016d0c6113c92f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — contract review PASS, clause-② gate PASS

Reviewed by an isolated at-tier review subagent, dispatched 2026-09-18T0739Z, returned 0755Z. Verdict PASS WITH FINDINGS, BLOCKING: none, CLAUSE-② REVIEW: PASS.

Tier attestation. CONTRACT_REVIEW_TIER is claude-fable-5-1 (scripts/pm/dispatch-gates.mjs:11899). This seat serves below that tier, so the review was routed to an isolated at-tier subagent rather than done in seat. The reviewer's own first line claimed the tier; that claim is not the evidence, because a subagent cannot self-attest — its get_session reads the parent session. The tier was verified instead from the reviewer's own transcript: 85 per-turn model stamps, all 85 claude-fable-5-1, zero stamps of any other model. Read 2026-09-18T0756Z.

Review footprint. Branch read via the explicit ref origin/claude/issue-18670-project-expressible-refinements, main via origin/main, no FETCH_HEAD and no working-tree reads. Head re-read fresh from the PR object three times (0740Z, 0751Z, 0752Z), unchanged at eae168e204b94a99d018779e66101ee1acdbab46. Two git fetches, no repo writes, no worktree. Executable measurements ran in the reviewer's own scratchpad against blob-identical copies of the branch modules.

Independent measurements the review added

Beyond judging the author's evidence, the reviewer measured four things itself:

  • allOf vs the ruling's literal nesting, with a real validator. ajv 8.20.0 (draft 2020-12) compiled the emitted node and a clone rewritten to the ruling's literal top-level anyOf; over 300 instances (dialect present/absent × 7 source values × 7 ast values including null, '', ' ', objects × 3 meta states, plus 6 non-object instances) the verdicts were identical 300/300, 60 accepted by both. Lit control: with the rule deleted, {dialect:'cel'} is accepted, and both spellings refuse it. 0751Z.
  • The two packages/spec/src/** edits, re-classified line by line. 18 changed lines = 6 comment, 2 import, 10 predicate lines forming 5 -/+ pairs; for every pair the text before .refine( and after the predicate argument (including the { message: … } object) is byte-identical, so only the first argument moved. Per-file token counts main→branch are flat: expression.zod.ts .refine( 4→4, .optional( 5→5, .strict( 0→0, z.object( 2→2, message: 5→5; protocol.zod.ts 1→1, 184→184, 1→1, 141→141, 10→10. Lit control: the shape-token regex hits 14 context lines of the same diff. 0746Z. This independently reproduces the seat's own 0-schema-shape-token reading.
  • The ledger at JSON level. keys 246→201, keys added 0, deleted 45; among the 201 survivors sites added 0, rows grown 0, shrunk 75, unchanged 126; sites removed 197 = 60 via deleted rows + 137 via shrunk rows; header equals body on both sides; measured.zod 4.4.3 and refinementSitesWithNoJsonFormToCompare 3 both unchanged; both files round-trip byte-identically through 2-space serialisation. Shrink-only in the strong sense. 0745Z.
  • Part of vs Fixes. 0 closing-keyword hits over body and title (close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved, optional owner/repo prefix); lit control on the synthetic string Fixes #18670 hits 1. 0741Z.

The two declared deviations, as resolved

1. allOf conjunction instead of the ruling's literal top-level anyOf. Accepted by this seat before the PR opened, on the measurement that the top-level spelling degraded 26 reference pages (182/182 lines) because scripts/lib/format-type.ts tests anyOf before properties; the wider alternative — teaching format-type.ts to skip pure-required branches, a shared renderer — was declined by name. The review's scope on this was exactly one question, validator-equivalence, and it came back identical 300/300.

The review also surfaced a stronger reason than the one the acceptance rested on: for a node that already carries a union anyOf (exercised at packages/spec/scripts/refinement-projection.test.ts:179), the literal spelling would overwrite that union, so allOf is not merely equivalent there but the only correct spelling. The acceptance is firmer than when it was made.

2. File surface beyond the claim's declared set (src/shared/expression.zod.ts, src/api/protocol.zod.ts). Necessary rather than incidental: the pattern must be declared where the refinement is written, because it cannot be read back out of a predicate. Independently re-verified predicate-only above. Stated separately in the PR body as this seat asked.

Non-blocking findings — disposition

Findings 1 and 2 are latent with no live instance on this head (verified at source: each declared call site is the only custom check on its node, and safeExtend adds none). They become required work for the next arm on this card, which is a named successor rather than a hope: #18670 stays open with two arms outstanding, and whoever takes them edits these exact files.

  1. Detector verdict granularity is per node, not per check. verdictFor/projectOrNull compares the node with all custom checks against the node with none, so any declared arm on a node marks the whole node projected. Measured on the branch's own collectDroppedRefinements: z.string().refine(NON_BLANK_STRING).refine(s => s.startsWith('x')) yields dropped: [] and projected: [{ count: 2, declaredPatterns: ['non-blank-string'] }]; lit control, the undeclared rule alone reads dropped. A second .refine()/.superRefine() chained onto any of the five declared nodes would then be neither in the ledger nor annotated, with the ratchet green and the generator's UNDECLARED line blind to it (it reports only sites with zero declared patterns) — silently violating the ruling's own A refinement that is not one of these named patterns stays dropped and annotated. Fix is small: treat a node as projected only when customs.length === declaredPatterns.length, else dropped conservatively, and/or add a report line for count > declaredPatterns.length.

  2. Generator/detector coupling is by convention, not construction — the higher-risk of the two, because its trigger is broader than the next arm. build-schemas.ts (three toJSONSchema calls, lines 503/512/531) and projectOrNull each pass the override independently. Dropping the override: argument on the generator side alone — a plausible merge-conflict resolution — leaves all 197 sites reading projected with a green ledger and a green gate while the published file goes wide again with no annotation: the item-1 silence restored, now behind a green ratchet. No ablation covers this leg (a mutates the shared emitter, b the detector side), and no test reads generator output for the projection (build-schemas-check-mode.test.ts has 0 hits for allOf|minLength|refinementProjection; control: 0 tests mention x-dropped-refinements either). Fix options: one shared projection helper imported by both, or a sandbox-builder pin asserting shared/Expression.json carries allOf[].anyOf[].required.

Why these are carried rather than required in this PR: there is no reachable instance today, the guard only becomes load-bearing once a mixed node or a divergent generator call can exist, and the next arm both creates that possibility and regenerates these files anyway. Requiring them here would buy a full re-verification cycle on an size/xl PR for protection against an edit nobody has made. Recorded as required work on the card instead, so the next dispatch inherits them as obligations and not as suggestions.

  1. Literal shortfall against the ruling, recorded. The ruling asks for a changeset its body naming the patterns projected and the rows retired. The changeset names both patterns and gives counts (45 deleted / 75 shrunk / 197 sites) plus two examples, but does not name the 45 retired rows. The names are in the ledger diff in the same PR, so the record exists; the at-tier reviewer judged the shortfall non-blocking, and on a question of contract faithfulness that judgement is the one that counts here, since this seat is below tier. The next arm's changeset should name rows.

  2. \S exactness is ECMA-262-relative, and the review made it concrete: strings consisting solely of U+0085 (NEL) or U+001C–U+001F are accepted by the runtime and by ajv, and refused by a Python re reading of \S. Lit controls: U+0020 refused by both, a accepted by both. Disclosed by the author as a specification appeal; no portable spelling exists (ECMA lacks \x{…}, RE2 lacks \uXXXX).

  3. No validator-backed pin ships in the PR although ajv is already a workspace devDependency (packages/lint, packages/objectql). The reviewer ran one; the repo does not.

  4. packages/spec/scripts/build-openapi.ts:95 projects nine hand-listed API contract schemas into json-schema/openapi.json, shipped in the same files[] directory, without the override. None of the nine carries a declared refinement today (UpdateAiConversationRequest is not among them: 0 hits, control BaseResponse 1), so nothing diverges now; a future arm touching one of the nine would ship two disagreeing machine-readable descriptions in one directory.

Reviewer's own instrument failures, recorded rather than buried

A lockfile control (grep -c '/zod@' over origin/main:pnpm-lock.yaml) returned 0 while the target zod@4.4.3 returned 21 — a control pattern wrong for lockfile-v9 syntax. The reviewer discarded that pair and read the importer entry positively instead (packages/spec: zod: version: 4.4.3). A dead control discarded is worth more than a reading kept.

Landing

Part of #18670, no closing keyword — deliberate, and the reason is on record: two of the ruling's four named arms are not taken (banned keys, measured at zero clean candidates; dependentRequired, one candidate at data/SSLConfig, 2 sites). The card therefore does not auto-close at merge, and the partial-landing sequence applies in one write: return #18670 to pm:queue, clear the assignee, and post a Release: line naming what landed and where the remainder goes, together with findings 1 and 2 as required work for the next arm.

CI at 2026-09-18T0756Z on head eae168e204b94a99d018779e66101ee1acdbab46: 35 distinct checks (raw 35, so no superseded runs), 31 success, 3 skipped, 1 in progress (Lint & Repo Gates), 0 failures, every latest run bound to the current head. TypeScript Type Check is success on this head, together with all four Type Check · * constituents including workspace — the earlier red on the superseded head 34aeb4261 is resolved by the author's final origin/main merge, not waived. Enqueue follows the last gate job reaching completed: success; ⛔ not an aggregate reading.


Generated by Claude Code

@os-elon-musk
os-elon-musk marked this pull request as ready for review September 18, 2026 08:01
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Clause-② carrier stripped from both carriers — record cited, and the seat's own error named

Why this comment exists. Governed Surface Queue Guard refused the merge group containing this PR at 2026-09-18T08:24:50Z and github-merge-queue[bot] ejected it. The guard's two legs separate cleanly in its log, and only the second one fired:

  • Governed-surface leg: ✅ CLEAR — 0 governed pull request(s), 0 unattributed governed commit(s). The pre-enqueue reading (check-governed-merges.mjs --pr 18952, 0 of 11 paths, exit 0, 2026-09-18T0759Z) was correct and is not what failed.
  • Contract-Review Carrier leg: ⛔ REFUSED — #18952 — CARRIES needs:contract-review — this pull request may not be in the queue, exit 6, citing 〈入队与落地〉「双肢命中任一 ⇒ 无席内条款②复核 PASS 在案 ⛔ 禁止入队」 and references/contract-review.md 〈载体纪律〉「开着的载体恒 = 真实待审」.

This was the dispatching seat's error, not a gate defect and not a flake. The protocol step is 「PASS ⇒ 同席剥标并引记录、ready、auto-merge」 — the strip comes FIRST. This seat had the PASS on record and went straight to ready + auto-merge with the carrier still hung, which is precisely the state the queue leg exists to refuse. The guard did its job; the ejection cost one queue cycle and nothing else.

The verdict this strip rests on was already on record before the enqueue, not manufactured after the refusal. Comment 5727034823 on this PR, posted 2026-09-18T07:58:44Z — i.e. 4 minutes BEFORE the enqueue at 08:02:49Z:

  • Isolated at-tier contract review, dispatched 0739Z, returned 0755Z. Verdict PASS WITH FINDINGS, BLOCKING: none, CLAUSE-② REVIEW: PASS.
  • Tier verified from the reviewer's own transcript — 85 per-turn model stamps, all 85 claude-fable-5-1, zero stamps of any other model — because a subagent cannot self-attest (its get_session reads the parent session). CONTRACT_REVIEW_TIER is claude-fable-5-1 (scripts/pm/dispatch-gates.mjs:11899) and this seat serves below it, which is why the review was routed out rather than done in seat.
  • The clause-② substance: the published artefact narrows only toward what the runtime already refuses, measured with a conforming validator (ajv 8.20.0, draft 2020-12) — 0 runtime-vs-file disagreements across a 300-instance presence/value lattice and 22 whitespace code points — and the runtime itself is unchanged (five predicate substitutions, each textually identical to what it replaced).

⛔ This is not a strip-to-pass. The guard's own text says the label leg cannot tell a carrier stripped before any PASS from one never hung, and that whether a verdict EXISTS is check-clause2-carriers.mjs's question — so that instrument is re-run after this strip and its reading is posted rather than asserted.

Both carriers were written, 9 seconds apart, each with the four-step write and a read-back that MATCHED the target: this PR (labels now documentation, size/xl, tests, tooling, domain:spec) and card #18670 (now priority:p1, pm:dispatched, domain:spec, assignee retained). A one-sided removal is indistinguishable from a strip, which is why neither was left alone.

Nothing about the change itself moved: head is still eae168e204b94a99d018779e66101ee1acdbab46, 35 distinct checks with 0 failures, and the ejection was a queue-admission refusal rather than a finding against the diff.


Generated by Claude Code

os-elon-musk commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 85/85 CONTRACT_REVIEW_TIER
Head-sha: eae168e204b94a99d018779e66101ee1acdbab46

Tier was verified from the reviewing subagent's OWN transcript, not from its self-report: 85 per-turn model stamps read, all 85 equal to the constant above, zero stamps of any other value. A subagent cannot self-attest, because its get_session reads the parent session rather than itself. This seat serves below that constant, which is why the review was routed to an isolated subagent rather than done in seat.

① Derived judgments

The published artefact narrows only toward what the runtime already refuses, and the runtime itself is unchanged.

  • File side, conforming validator. ajv 8.20.0 (draft 2020-12) compiled the emitted node and a clone rewritten to the ruling's literal top-level anyOf. Over 300 instances (dialect present/absent, 7 source values, 7 ast values including null, empty and blank strings and objects, 3 meta states, plus 6 non-object instances) the two verdicts were identical 300/300, 60 accepted by both. Lit control: with the rule deleted {dialect:'cel'} is accepted, and both spellings refuse it. A further 22 whitespace code points gave 0 runtime-versus-file disagreements.
  • Runtime side, unchanged by construction. Five predicate substitutions across two contract sources, each textually identical to the predicate it replaced. 18 changed lines classify as 6 comment, 2 import, 5 minus/plus predicate pairs whose text before .refine( and after the predicate argument is byte-identical. Per-file shape-token counts are flat: expression.zod.ts .refine( 4 to 4, .optional( 5 to 5, .strict( 0 to 0, z.object( 2 to 2, message: 5 to 5; protocol.zod.ts 1 to 1, 184 to 184, 1 to 1, 141 to 141, 10 to 10. Lit control: the same token regex hits 14 context lines of that diff.
  • Author's contract reading, judged for coverage rather than re-run. 6027 documents across 12 schemas parse byte-identically at the merge base and at this head (cmp exit 0, matching sha256), with a lit control moving 732 of the 6027 when the non-blank predicate is weakened by one token. All five source-level .refine() call sites from which the 197 closed published sites descend are exercised by those 12 schemas.
  • Ledger monotonicity. Keys 246 to 201, 0 added, 45 deleted; among the 201 survivors 0 sites added, 0 rows grown, 75 shrunk; header equals body on both sides; the measured zod version and the no-JSON-form count unchanged.
  • Deviation accepted before the PR opened. The ruling names anyOf plus required; the same pair is emitted conjoined through allOf, because the top-level spelling degraded 26 reference pages (182 of 182 lines) via scripts/lib/format-type.ts testing anyOf before properties. The wider alternative, teaching that shared renderer to skip pure-required branches, was declined by name. Review scope on it was validator-equivalence alone: identical 300/300. The review additionally found that on a node already carrying a union anyOf (exercised at packages/spec/scripts/refinement-projection.test.ts:179) the literal spelling would overwrite that union, so allOf is the only correct spelling there.

② Semver level

@objectstack/spec minor, with the BREAKING annotation — the documented combination for a clause-② narrowing rather than a major. ADR-0087 disposition not-required (no-migration-prescription), in the gate's own spelling, accepted by check-adr-0087-registration as BREAKING plus clause-②-narrowing. Recorded shortfall against the ruling's literal ask: the changeset gives counts (45 deleted, 75 shrunk, 197 sites) rather than naming the 45 retired rows; the names are in the ledger diff in the same PR, and the at-tier reviewer judged the shortfall non-blocking. The next arm's changeset should name rows.

③ Boundary flags

  • Clause-② narrowing: yes, declared line-initial on both carriers.
  • Governed surface: none — check-governed-merges.mjs --pr 18952 derived 11 paths three-dot and hit 0 of 5 surfaces, exit 0.
  • Release-owned surface: untouched — 0 paths under content/docs/releases/, control 16 files present there on main.
  • Card closure: Part of, no closing keyword bound to the card (0 hits over body and title, lit control 1 on a synthetic Fixes string), because two of the ruling's four named arms are not taken.
  • Public API surface: unchanged — the two new modules are exported from no barrel.
  • Carried to the next arm, neither with a live instance on this head: detector verdict granularity is per node rather than per check, so a second refinement on a declared node would be neither ledgered nor annotated while the ratchet stayed green; and the generator/detector override coupling is by convention only, so a merge-conflict resolution dropping it generator-side would leave a green ledger over a wide file, a leg none of the three ablations covers.

Implemented-by: claude/issue-18670-project-expressible-refinements
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

VERDICT: PASS

Full reviewer report and its per-item table: comment 5727034823. Carrier clear and the seat error behind it: comment 5727351098.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e the 27 signature hashes (objectstack-ai#18971)

Fixes objectstack-ai#16045

Clause-②: yes (widening)

Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer
verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a
readable declaration-text snapshot, ⛔ not a hash. The card body's three
mutually exclusive routes predate that ruling and were not re-litigated
here.

`@objectstack/spec` pinned its public surface on one axis.
`api-surface/` records each export as `name (kind)`, and a signature
change, a renamed interface field and a dropped union member move
**none** of those rows. The only shape pin was
`api-surface-signatures.json`: 27 rows, and reference-level even there.
This adds `api-surface-declarations/`, the declaration text the packed
build actually emits for every export of every published entry point,
and retires the 27 hashes it subsumes.

## The counts, re-derived on this head before the first generation

The ruling asks for this by name; the card's own numbers were
self-declared unverified and 12 days old.

| Number | Card | This head (`b33898f5d`) | Unit, and what would make it
something else |
|---|---|---|---|
| entry points | 17 | **17** | type entry points in the `exports` map —
those whose `require.types` ends in `.d.ts`. Adding or removing one such
subpath. |
| `exports` map entries | (not stated) | **19** | every key in the map.
The extra two are `./openapi.json` and `./package.json` — asset subpaths
with no declaration at all, filtered out by the same `.d.ts` test
`build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is
right and the map did not grow**; 19 counts two things that were never
entry points. |
| pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17
`api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 =
0.51%, so the headline 99.5% stands. |
| distinct exported names | (not stated) | **5200** | (entry, name)
pairs. The gap to 5336 is dual-declared names, which are two rows by
design. |
| signature hashes | 27 | **27** | top-level keys of
`api-surface-signatures.json`. Bright control: the first value really is
a `sha256:` string, so this counts signature entries and not empty
objects. |

Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve
through the map (3,215,437 bytes for the root entry down to 13,081 for
`./integration`). No entry point lacks a packed declaration, so the gap
the dispatch reserved for itself did not open.

## What the artefact costs — premise 4, which nobody had costed

| | |
|---|---|
| shards | 17, one per entry point |
| declaration blocks | 5336 |
| bytes | **12,661,943 (12.08 MiB)** |
| lines | **237,706** |
| gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB
compressed `dist`, so about **+5.8%** of tarball |
| largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines |
| median declaration | **81 bytes** |
| skew | the 20 largest declarations hold **~65%** of all bytes; four
exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868,
`ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851,
`ChangeSetSchema` 20,395) |

Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed
`.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its
**fully expanded** structural type. That expansion is exactly what makes
an inner field rename visible — and it is also why a single schema can
produce a 21,000-line diff. The ruling's stated reason for choosing text
over a hash is that the contract-review seat reads the diff; that
reasoning holds per declaration and is worth a second look at the top
twenty. One reading, for whoever wants it: 31% of declarations hold
97.7% of the bytes, so nothing cheap is available by trimming the tail.

## Both instruments, measured on one tree at one commit

The card's thesis is that the old pin cannot fail on a shape change. Not
argued — ablated, with the mutation proven on disk by blob hash and the
mutation proven to have reached `dist/` before any verdict was read.

**A. the source-level control — a renamed interface field, the card's
own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in
`packages/spec/src/contracts/job-service.ts` (blob `363443e2` to
`d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming
the marker reached the built artefact:

```
check:api-surface              exit=0    "public API surface unchanged"      [BLIND]
check:api-surface-declarations exit=1    "~ JobRunOutcome (interface)"       [SEES IT]
```

Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight
--absent` exit 0 (marker gone from all 214 built files), `git diff HEAD`
clean, gate back to exit 0.

**B. the gate can fail on its own artefact.** One field renamed inside
`qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1,
deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`,
failure text naming the regenerate command. Restored to the HEAD blob,
`git diff HEAD` empty: exit **0**.

## The retirement, and the coverage proof the ruling demands

All **27** signature names resolve to a declaration block in
`api-surface-declarations/root.txt`, **0 missing** — enumerated from
`defineAction` through `defineWebhook`, each as `(function)`.

One honest qualification, because the subsumption is not uniform. For
those 27 factory declarations the text is `declare function
defineAction(config: z.input of ActionSchema): ActionParsed;` — a type
**reference**, exactly as blind to an inner-key narrowing as
`typeToString` was. What is gained is not sharper text on the 27; it is
the **5309 other declarations**, including `ActionSchema` itself, whose
own expanded block is where such a narrowing shows up. So the retirement
is a strict superset of pinned declarations, not an equal trade. Nothing
published read the retired file — it was never in this package's
`files[]`.

## Where it lands, and why there

- Generator: `packages/spec/scripts/build-api-surface-declarations.ts`,
beside the eight sibling artefact generators, reading the same input
through the same `collectEntries` logic. The ruling says "one generator
script under `scripts/`"; this reads that as the directory the whole
family lives in, because the artefact reads the **built dist** and only
the lane that builds spec can run its gate.
- Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a
sibling **directory** of `api-surface/`. Not inside it: `listShardNames`
throws on any file in that directory that is not a `NAME.json` shard, so
`api-surface/` is closed by construction. No existing
`api-surface/*.json` is regenerated by this PR (`check:api-surface`
green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319.
- Gate: `check:api-surface-declarations`, a step in lint.yml's `Type
Check · consumer gates` lane after the two build steps, with
`check:api-surface` and the other dist-reading gates. **No new required
context** — a step in an existing lane. Registered in the
`check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes`
as `merge=os-regen`.
- Sharded per entry point from day one, for the reason its neighbour is:
the merge queue rebuilds server-side where no custom driver runs, so two
PRs sharing one generated file evict the second. Pit 1 from `5715457322`
is answered by the layout rather than by an assumption — and
`check:merge-driver`, which reconciles `.gitattributes` against
`REGEN_ARTIFACTS` in both directions, is green over the swap.
- Published, with the reason the gate demands. `check:published-files`
refuses a `files[]` entry that carries none; the registered line says
what a consumer does with it — read two published tarballs and see
*which declared shape* moved between releases, the question
`api-surface` cannot answer. If 1.02 MiB of tarball is judged too much,
one line of `files[]` removes it without touching anything else.

Three registries had to learn about the new gate, each because it
discovered the gate on its own rather than because a list named it:

- `check:published-files` — demanded the reason above.
- `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new
gate a population before anything listed it, which is the eighth member
of a class whose seventh was recorded the same way. Declared as
`CLASS_EIGHTH`, with a case asserting the edge really reaches it.
- `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is
derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped
it off that surface and reddened two self-test cases. Both are
retargeted to state the retirement as a counterfactual (the surface
follows the table, not a literal); ⛔ the new artefact is **not** added
to that surface, because the ruling assigns "is a snapshot diff a
Clause-② signal" to the skills seat by name and out of this card's
scope. Both directions are now pinned, so the boundary is declared
rather than forgotten. 483 cases pass, up from 481.

## Verification

- **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` from the merge base, 120
commands, every exit code redirected to a file and read back. **All 120
green.** Four returned exit **3** PREREQUISITE NOT MET on first pass
(`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`); each names a
build, each was built and re-run green, and none is recorded as a
finding. Reconciled with `--ran`.
- **Tests**: `@objectstack/spec` local project **488 files / 14,182
tests passed**; the tooling suites that name the edited scripts, both
projects, **10 files / 220 tests passed** (`sharded-artifacts`,
`check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`,
`api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`,
`def-key-collisions`, `root-index`, `export-list`,
`docs-import-surface`). `pnpm --filter @objectstack/spec typecheck`
green.
- **eslint, the union rather than a narrowing**: `eslint .
--no-inline-config --format json` at `b33898f5d` examined **6856
files**, **0 errors, 0 warnings**, exit 0. The population is eslint's
own config resolution and the count is read from its JSON output;
type-aware linting is not enabled in `eslint.config.mjs` (no
`parserOptions.project`, no typed rules), so this diff cannot move an
untouched file's verdict either way.
- **Control bytes**: `check:nul-bytes` green over 8906 files, plus a
direct scan of all 31 changed paths for the wider control-byte class —
no matches.
- `scripts/check-single-claim-paths.mjs` in the diffstat is **not
mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`)
this PR carries.

## Acceptance notes

- `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists
`api-surface-signatures` as an instance of a retirement shape, and that
row goes stale with this landing. ⛔ Left untouched on purpose:
`.claude/**` is a governed surface, so editing it would make this whole
PR maintainer-landed for a one-word prose nit. Noted, not filed.
- `packages/spec/scripts/build-schemas.ts` line 830 carries the same
stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted,
not filed, with the later lander as the natural carrier.
- Three files in this diff are held by open PRs and were edited anyway
because the retirement forces it, not by choice:
`scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948),
`scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and
`.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are
hand-written files where a text conflict is visible rather than silent,
and all three of my hunks are small and far from theirs. Whoever lands
second resolves.
- The top-20 skew above is a reading, not a finding: no gate is wrong
and nothing is unenforced. It is recorded here because the ruling's own
justification for text over hash is per-declaration readability, and at
21,000 lines a declaration that argument thins out.

## 维护者速读(草稿)

**改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后
`.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI
闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。

**为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27
行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 ——
全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface`
退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60
里您逐字「同意」的那一条。

**风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约
5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2
万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从
`files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。

**席位意见。**

**你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff
可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ion's two halves one call (objectstack-ai#19005)

Part of objectstack-ai#18670 — item 2, the **third** of the ruling's four named arms.
objectstack-ai#18670 remains open: banned keys is still untaken, and this body
deliberately carries no closing keyword for that number.

Clause-②: yes (narrowing)

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

Continues PR objectstack-ai#18952 (squash `5e5ec9fa42194723cc523a274e7221c8447c4487`),
which landed `required-one-of` and `non-blank-string`.

## 1. The arm: `dependentRequired`

`data/SSLConfig`'s refinement is `hasCert === hasKey` — precisely
`dependentRequired { cert: ['key'], key: ['cert'] }`. It is emitted
through the same closed-vocabulary mechanism the previous arm built:
`src/shared/refinement-projection.ts` declares,
`scripts/lib/refinement-projection.ts` emits. No second mechanism was
introduced.

**Exact, not approximate.** A key absent from a JSON object is the only
way for its value to read `undefined`, and `dependentRequired` triggers
on PRESENCE — so a key present with any JSON value, `null` included,
arms its dependency exactly as the predicate's `!== undefined` does. The
dependency map is read once into the declaration and the predicate reads
it from there, so the published keyword and the enforced rule cannot
name different keys.

### Ledger: the rows retired, by name

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

| row | before | after |
|:---|:---|:---|
| `data/SSLConfig` | `sites: [""]` | **deleted** — drops nothing now |
| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` —
the `sslConfig` site closed |

1 row deleted, 1 row shrunk, **2 sites closed, 0 sites added anywhere**;
the ledger diff is deletions only. Generator census after: 551 dropped
across 200 published schemas, **199 projected** — 137 `required-one-of`,
60 `non-blank-string`, **2 `dependent-required`** — 3 undecidable.

`data/SQLDriverConfig`'s remaining `""` site is its **own** separate
rule, "`sslConfig` is required when `ssl` is **true**". That judges a
VALUE, is `if`/`then` rather than this arm, and correctly stays dropped
and annotated.

### Banned keys (`propertyNames` / `not`) — NOT taken, and not forced

Confirmed against the tree, not assumed: the nearest sites judge a
banned VALUE on a string (`FILTER_ARRAY_LOGIC_KEYWORDS`) or an allowed
key set that is data-dependent (`ai.paramHints` against the action's own
params). Neither is mechanically derivable, so **no candidate was
constructed**. This is why the body says `Part of` and carries no
closing keyword.

## 2. Mechanism fix A — the verdict is per NODE, the rules are per CHECK

`verdictFor` compared a node with ALL custom checks against the node
with NONE, so any one declared arm marked the whole node `projected`.
Reproduced on the landed code before changing it:

```
mixed(declared+undeclared)  dropped: []   projected: [{count: 2, declaredPatterns: ['non-blank-string']}]
undeclared-alone  (lit)     dropped: [{count: 1, declaredPatterns: []}]   projected: []
declared-alone    (lit)     dropped: []   projected: [{count: 1, declaredPatterns: ['non-blank-string']}]
```

A second refinement on a declared node was therefore neither ledgered
nor annotated, and the generator's UNDECLARED line could not see it —
silently violating the ruling's own 「A refinement that is not one of
these named patterns stays dropped and annotated」.

**Fix:** `projected` now requires `customs.length ===
declaredPatterns.length`; anything else is `dropped` conservatively. The
RAW differential is kept as a new `projectionMoved` field so the
detector still MEASURES rather than asserts — collapsing it would have
made the instrument blind to the zod upgrade it exists to notice — and
the generator prints partially-stated sites on their own line.

**Ablation, both directions** (anchor-verified on disk,
`scripts/ablation-replace.mjs`):

| leg | blob | result |
|:---|:---|:---|
| mutated — drop the `total === stated` guard | `54ed82dbe4c2` to
`2c1bff777363` | **1 test red**, 42 green: "a DECLARED arm beside an
UNDECLARED rule stays `dropped`" |
| restored | back to `54ed82dbe4c2`, `git diff HEAD` empty | **43 / 43
green**; mutant text on disk 0, guard text 1 |

## 3. Mechanism fix B — generator/detector coupling, by construction

`build-schemas.ts` (three `toJSONSchema` calls) and `projectOrNull` each
passed the `override` independently. **Measured on the pristine base**
with only the generator's import stubbed out:

| leg | gate exit | `shared/Expression.json` `allOf` |
`x-dropped-refinements` | files carrying the non-blank pattern |
|:---|:---|:---|:---|:---|
| base, untouched (dark control) | 0 | present | absent | **35** |
| base, generator-side override dropped | **0 — GREEN** | **absent
(wide)** | **absent (SILENT)** | **0** |

Census identical to an untouched run (553 / 201 / 197). That is the
item-1 silence restored, standing behind a green ratchet — worse than
the state the card was filed about, because the ledger now certifies it.
A merge-conflict resolution was enough to cause it.

**Chosen fix: one shared projection helper** —
`projectPublishedJsonSchema` in `scripts/lib/refinement-projection.ts`.
All three generator calls, the union-branch projector behind the third,
and the detector's differential now reach `z.toJSONSchema` through it,
and `projectByPruningUnionBranches` no longer takes an `override` option
at all. There is no argument left for a caller to forget.

**Why the sandbox-builder pin was rejected**, not overlooked: a pin
*detects* after the fact and can be skipped, deleted or made vacuous,
and it leaves the two-argument shape in place so the next merge conflict
can still separate them. The choke point makes the one-sided failure
**unrepresentable** rather than caught. Both halves now lose the
override together or not at all — which is what turns the ablation from
silent into loud. The test file's own `publish()` helper was rewired
through the same call for the same reason, so the unit pins measure the
real seam rather than a re-spelling of it.

**Ablation, both directions:**

| leg | gate exit | `Expression.json` `allOf` | `x-dropped-refinements`
|
|:---|:---|:---|:---|
| mutated — override removed from the ONE helper | **1 — RED**: 46
undeclared schemas + 76 miscounted ledger entries | absent (wide) |
**present (annotated)** |
| restored | 0 | present | absent |

The contrast is the whole point: before, one-sided removal was green and
silent; now it is red **and** the file confesses.

## 4. Contract: the published file narrows toward what the runtime
already refuses

**Whole published tree, base vs head:** 1530 of 1532 files
byte-identical. The two that move are `data/SSLConfig.json` and
`data/SQLDriverConfig.json`, each gaining `dependentRequired` and losing
the matching `x-dropped-refinements` row. Nothing else in
`packages/spec/json-schema/**` changed.

**Parse-equivalence probe — 10,368 documents** (2,592 SSLConfig-shaped
over the full presence lattice of 4 keys times 6 value shapes including
`null`, a wrong type and an unrecognised extra key; 7,776
SQLDriverConfig documents embedding each of those under three `ssl`
states). Published-side verdicts computed with ajv 8.20.0 (draft
2020-12) against the two real snapshots.

| reading | SSLConfig | SQLDriverConfig |
|:---|--:|--:|
| documents | 2,592 | 7,776 |
| runtime accepts | 60 | 180 |
| published accepts, base | 27 | 54 |
| published accepts, head | 15 | 30 |
| **narrowed by this arm** | **12** | **24** |
| widened | 0 | 0 |
| **documents the runtime ACCEPTS that the published file now refuses**
| **0** | **0** |

**Runtime behaviour did not move.** The runtime verdict vector is
byte-identical at merge base and head over all 10,368 documents — sha
`9e7c848f04e0c687` (SSL) and `4f18f835d4d1a62e` (SQL) on both sides. The
base leg was run against the real base blobs (`git checkout` of the two
source files at `d8b12fca9`, blob hashes asserted both ways, restore
proven by an empty `git diff HEAD`), not against a retyped predicate.

**LIT CONTROL for that zero** — weakening the dependency map to one
direction (`{ cert: ['key'] }`) moves **96 documents** (24 SSL + 72 SQL)
and lifts runtime accepts from 60 to 84 and 180 to 252. The zero is a
reading, not a silence.

Note the published-accepts figures sit below runtime-accepts on both
sides: `SSLConfig.json` is the OUTPUT shape and lists
`rejectUnauthorized` as required because the runtime applies its
`.default(true)`. That asymmetry is pre-existing, is the `x-io`
convention, and is unchanged by this PR — it is reported rather than
netted out.

## 5. Verification

- **Gates:** derived from the merge base with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, re-derived after the `origin/main` merge (identical, **84
commands**). Every exit code captured by redirecting to a file first,
never through a pipe. **80 exit 0, 0 findings.** The remaining 4 —
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt` — exit **3**, which
those gates define as `PREREQUISITE NOT MET` ("Nothing was measured ...
It is NOT a finding"): each reads BUILT output of packages outside this
diff. They are **NOT MEASURED**, not red; the re-run against a full
build is reported on the card.
- The derivation's own caveats are carried, not netted out: 50
artifact-roster families score `silent` for every card in the tree, 11
declare a population too wide to place, 5 take a value from the
workflow, and 5 path-scheduled CI jobs run 30 steps with no local
invocation. None of those is a clearance, and CI owns them.
- **`pnpm --filter @objectstack/spec check:generated`:** all 16
generated artifacts up to date. **`content/docs/references/**` does not
move** — see acceptance notes.
- **Targeted tests:** `scripts/refinement-projection.test.ts`,
`scripts/dropped-refinements.test.ts`,
`scripts/union-branch-projection.test.ts` — **91 / 91**. `packages/spec`
typechecks clean (`tsc --noEmit` over both the package and
`tsconfig.scripts.json`). The full `@objectstack/spec` suite reading is
on the card.
- **Lint, declared narrowing:** eslint run over the 9 changed lintable
files, 0 errors / 0 warnings, file count read from `--format json`. The
population is `eslint.config.mjs`'s own `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the config states in its own
words that this repo "never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules) for ANY
file", so this diff cannot move the verdict on a file it does not touch.
The repo-wide sweep is CI's run.
- `origin/main` merged through `bash scripts/pm/os-regen-merge.sh` (no
rebase, no force-push). It brought one docs-only commit, objectstack-ai#18979,
overlapping none of this branch's paths and no `merge=os-regen` path.
The previous arm's implementation body was asserted still present by
quoted-exact-name `git grep` against `origin/main`, with a dark control
at 0.

## Acceptance notes

Noted, not filed — out of scope for this card and not one of the three
filable classes:

- `packages/spec/scripts/build-schemas.ts` (the authorable-surface
docblock, near line 846) still names the retired
`api-surface-signatures.json`. The previous seat handed this to "the
next editor of `build-schemas.ts`", which is this PR. It is left
untouched deliberately: it is a stale code comment, not a defect, a
contract violation or an authoring trap, and the bounded in-place
exemption requires the finding to be **the same defect class as this
card**, which it is not. Carrier: the next PR that edits that docblock
for its own reasons.
- The dispatch's overlap warning — that a new keyword might move
`content/docs/references/**`, four pages of which open PR objectstack-ai#18985 edits —
**measured FALSE**. `dependentRequired` is a sibling keyword the
reference renderer does not read, `check:docs` is green and
`check:generated` reports all 16 artifacts current. No reference page
moves, so there is no collision with objectstack-ai#18985 on that directory.

Reported for the seat to file (a candidate class-(b) finding,
deliberately NOT fixed here):

- `packages/spec` ships `src/**/*.zod.ts` in `files[]`, and
`scripts/check-published-files.mjs` allows it with the reason "The Zod
schemas are themselves the contract (Prime Directive objectstack-ai#1); **downstream
code imports them directly**, so these sources are product rather than
build input." Two measurements contradict that reason: (1) the package's
`exports` map exposes no `./src/*` subpath and no wildcard, so no
consumer can import those files at all; (2) 188 of the 202 shipped
`*.zod.ts` files carry a relative import resolving to one of 35 modules
under `src/` that the glob does NOT ship (`src/shared/lazy-schema.ts`
alone is imported by 181 of them), so they would not resolve even if
reachable. Overwhelmingly pre-existing and far outside this card; this
PR adds the third importer of one of those 35. Not verified by `npm
pack` and not by a real consumer import — that is the next step for
whoever takes it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rces (objectstack-ai#19137)

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

Clause-②: yes

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

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

---

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

## 1. The arm

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

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

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

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

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

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

## 2. The rows retired, by name

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

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

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

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

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

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

The issue body cites `system/TraceSamplingConfig.json`:

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

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

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

After:

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

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

## 4. Blast radius, measured on the whole published tree

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

| reading | value |
|:---|:---|
| per-schema files common to both trees | 1530 |
| **byte-identical** | **1528** |
| moved | **2** — `system/TraceSamplingConfig.json`,
`system/TracingConfig.json` |

The diff of each moved file is exactly: **gain** the `allOf` ban,
**lose** the matching `x-dropped-refinements` row. Nothing else in
either file changes. (The revert leg was proven on disk — each path's
blob hash equalled its `origin/main` blob — and the restore leg by `git
diff HEAD` printing nothing.)

`openapi.json` was measured **separately and by the right instrument
this time**: `gen:schema` never writes it, so the first comparison read
two missing files and reported a false MOVED. Running `gen:openapi` on
both trees gives a byte-identical file, sha256
`34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on
both sides.

## 5. Ablation — the pins can fail, both halves

Re-run on the **new** head; the earlier ablation measured a tree that no
longer exists. `scripts/ablation-replace.mjs` replaced the one line
dispatching the arm (`emitBannedKeys(jsonSchema, declared.keys);`) in
`scripts/lib/refinement-projection.ts`, with the mutation verified
against the disk (anchor 1 → 0, blob `0a21fb6f9b66` → `6e55fe06cef5`):

| leg | result |
|:---|:---|
| `refinement-projection.test.ts` | **exit 1** — 12 failed / 46 passed,
including the live seam and the ledger-verdict pin |
| `gen:schema` | **exit 1** — naming **both renamed rows**
(`composite.element.condition`, `sampling.composite.element.condition`),
each record/aborting |
| restore | blob back to HEAD, `git diff HEAD` empty |

The second leg is the one that matters for the ledger's whole purpose:
with the emitter gone, the two deleted rows come **back** as undeclared
gaps. The row deletion is load-bearing, not decorative.

## 6. What is left, measured rather than estimated

`src/data/filter.zod.ts:1916` bans **every key starting with `$`** on a
normalized field condition, and it reaches **THREE** published record
nodes in `packages/spec/json-schema/data/NormalizedFilter.json`:

- `properties.$and.items.anyOf[0]`
- `properties.$or.items.anyOf[0]`
- `properties.$not.anyOf[0]`

Measured on this head: **all three publish as a bare object** with
`propertyNames: { type: 'string' }` and **no ban**, none of them appears
in that file's `x-dropped-refinements`, and the file **PASSes a document
the runtime refuses** — the runtime's answer for that document names the
rule: 「a field condition's keys are field names, never `$`-prefixed
operators」.

All three read **`undecidable`** to the detector, because
`FieldOperatorsSchema` carries `z.date()` members that throw in both io
directions — so they hold **0 ledger rows** while the branch-pruning
path publishes them anyway. ⭐ **Published yet undecidable is a ratchet
blind spot in its own right**, and it deserves a line of its own on the
card's worklist, separate from the fifth arm it would take to close.

Closing the rule itself is a second public-contract decision, not a
refactor of this one: an open key set cannot be spelled as a finite
`keys:` list — a list that merely sampled the open set would be WIDER
than the rule, which the closed list forbids by construction. It needs a
pattern-shaped declaration (`propertyNames: { not: { pattern: "^\\$" }
}`). ⇒ closing it is a real narrowing with **no ledger row to make it
testable**, which is the opposite trade from this arm.

⭐ The changeset now says the same thing. An earlier revision of it
claimed these sites 「stay unprojected and **keep their annotation**」,
which is false on the tree; the at-tier review caught the disagreement
between the two carriers and the clause was corrected before landing.

`src/ui/action.zod.ts:1844` stays dropped and annotated, correctly: its
allowed key set is computed from the sibling `data.params`, and JSON
Schema cannot express "property names drawn from another array field's
values".

## 7. Verification

Run on head **`184615ded9`**, each exit code captured **before** any
pipe.

⭐ **The at-tier contract review returned PASS**, on head `384d27ac18`
(record: PR comment `5737573936`). The branch has moved once since, by
exactly one prose clause in one changeset file (`git diff --stat
384d27a 184615d` → `1 file changed, 1 insertion(+), 1
deletion(-)`), so the contract surface the review judged is
byte-unchanged and `needs:contract-review` is cleared on both carriers
(record: `5737671517`).

⚠️ **Any count of this suite is only meaningful beside a statement of
whether `packages/spec/dist` was built** — the two readings below are
both correct, of different trees:

| tree | Test Files | Tests |
|:---|:---|:---|
| **without** `packages/spec/dist` | `496 passed \| 1 skipped (497)` |
`14562 passed \| 1 skipped (14563)` |
| **with** `packages/spec/dist` built | `497 passed (497)` | `14564
passed (14564)` |

The discriminator is
`packages/spec/scripts/root-entry-type-nameability.pin.test.ts`, which
takes a **dist-freshness branch at collection time** — ⛔ not a platform
check and ⛔ not a bare env var. Not fresh ⇒ it registers exactly one
test, `it.skipIf(!EXPECT_BUILT_DIST)(…)`, whose NAME carries the
freshness state and the rerun command. Fresh ⇒ it registers two (the
declaration-emit pin and its canary). `OS_EXPECT_ROOT_NAMEABILITY=1`
does not cause the skip; it only turns the skip into a failure for a
lane that expects a built dist. ⇒ `14562 + 1 skipped = 14563`, `14562 +
2 = 14564`.

| check | result |
|:---|:---|
| `pnpm --filter @objectstack/spec test` | **0** — see the two readings
above; the count depends on whether `dist` was built |
| `pnpm --filter @objectstack/spec typecheck` | **0** |
| `pnpm --filter @objectstack/spec build` | **0** |
| `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced
|
| `pnpm --filter @objectstack/spec gen:openapi` | **0** — `openapi.json`
byte-identical to base |
| `pnpm --filter @objectstack/spec check:generated` | **0** — 16/16
generated artefacts current |
| derived gate families (`scripts/pm/dispatch-gates.mjs --ran`) | **82
derived / 78 run, ALL exit 0 / 4 NOT MEASURED / 0 UNRUN** |

The four NOT MEASURED are `check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` — each exits **3** (`PREREQUISITE NOT MET`, a
code that is explicitly neither pass nor failure) because each needs a
whole-repo build closure that CI's Build Core / lint.yml produces. They
are **declared, not skipped**. ⭐ The earlier count of 77/74/3 was taken
**before the changeset file entered the change set**; the five families
the changeset brings in (`check-empty-changeset` ×2,
`release-rehearsal-clone --self-test`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`) all exit 0. Under-reporting a NOT
MEASURED as "tested" is the exact inverse of this lane's reading
discipline, and the PR body is where a reviewer reads the coverage
claim.

`packages/spec` has no workspace dependencies, so the dependency-closure
build is empty; the public **entry** surface is unchanged
(`src/shared/refinement-projection.ts` is not re-exported from
`src/shared/index.ts`, which is why `check:api-surface` and
`check:api-surface-declarations` both stay green with no artefact
regeneration).

## Acceptance notes

- **`dropped-refinements.baseline.json` is a shared hot file.** It is a
generated, shrink-only ratchet that every holder regenerates, so a
collision resolves by **regenerating** (`scripts/pm/os-regen-merge.sh`),
⛔ never by hand-editing conflict markers. This PR did not wait on it.
- **F1 was fixed by MERGING, never rebasing.** `origin/main` was merged
into the branch (merge `f66984fb1a`); ⛔ no history on this branch was
rewritten.
- **Noted, not filed — `scripts/build-schemas.ts:830` still carries a
stale mention of the retired `api-surface-signatures.json`.** objectstack-ai#19005's
release named the next editor of that file as its carrier. This PR does
not edit `build-schemas.ts` at all, so it does not become that carrier.
Carrier: the next PR that edits
`packages/spec/scripts/build-schemas.ts`.
- **Noted, not filed — the `build-openapi.ts` branch still has no live
sample.** Another seat measured that all nine schemas it projects read
`declaredProjectable=0`. This arm's two sites are not among them, and
`openapi.json` is byte-identical across this change. Carrier: whoever
next teaches an arm a site that OpenAPI publishes.
- **Receipt — Docs Drift Check on this head.** The bot derived 5 anchors
from 1 changed package and found **no hand-written page naming any of
them**; it also declares that
`packages/spec/dropped-refinements.baseline.json` yielded **no anchor**,
so pages documenting that file are **NOT COVERED by that run** —
explicitly not a clean bill of health. Read and carried here rather than
left unanswered: the ledger is a machine-maintained ratchet with no
hand-written reference page to drift against, and this PR's edit to it
is two row deletions plus a re-snapshot of its own `measured` block. ⚠️
It also notes its tree was the MERGE of this head into the base, not the
head.
- The test file's roster pin previously read "names exactly the two arms
this change landed" while listing three; it now reads "the arms this
list has landed, and nothing else".

---

## HISTORY — what this body used to say, kept rather than deleted

⛔ Three claims were carried by earlier revisions of this body and are
**withdrawn**. They are recorded here because a correction that deletes
its own subject is not a correction.

1. **「objectstack-ai#19005 的发布说明写错了,那次普查走到 X 就停了」** — WITHDRAWN and refuted on the
trees: the `dialect` predicate was introduced by objectstack-ai#18638, **after** both
`5e5ec9fa42` (objectstack-ai#18952) and `72c1640504` (objectstack-ai#19005). At those commits the
slot carried zero custom checks and no ledger row, so both zeros were
correct readings of their own trees. The correct statement is §0's: the
candidate set is time-dependent.
2. **`Clause-②: no`** — WITHDRAWN. The claim comment declared `no`,
which is wrong on the ruling's own axis: a published artefact narrows.
`check-clause2-carriers` separately judged **C5 广化线索** at
`src/shared/refinement-projection.ts` (the as-const
`PROJECTABLE_REFINEMENT_PATTERNS` roster gaining `banned-keys`), and the
precedent is exact: `required-one-of` (objectstack-ai#18952) and `dependent-required`
(objectstack-ai#19005) both shipped `yes` for additions to that same array. ⚠️ The
at-tier review then measured that roster to be an **internal** export
that reaches no entry barrel, so the tell did not have to carry the
verdict. Both carriers now declare `yes`, and all three carriers —
claim, body, changeset — agree.
3. **`77 derived / 74 exit 0 / 3 exit 3`** — WITHDRAWN, superseded by
§7's `82 / 78 / 4`.

**Attribution (prose, because the edit side of a PR-body write always
appends its own footer):** this body was written by the `domain:spec` PM
seat in session `session_01AmH9bKvGoLjiY86Q4Z3og2`; the change itself
was implemented by the dispatched dev on branch
`claude/issue-18670-banned-keys-projection`.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation domain:spec size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants