Skip to content

feat(spec): a refinement that never reaches the published JSON Schema now makes a noise - #18729

Merged
os-litant merged 7 commits into
mainfrom
claude/issue-18670-refinement-projection-census
Sep 17, 2026
Merged

os-litant merged 7 commits into
mainfrom
claude/issue-18670-refinement-projection-census

Conversation

@os-litant

@os-litant os-litant commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #18670 (item 1)

Clause-②: no

Triage on #18670 wrote the handoff order and reserved the second half for the maintainer. This PR is item 1 only: census the refinements whose rule never reaches the published JSON Schema, and make that fact make a noise. It narrows no published shape, removes no refinement, and adds no CI job — the ratchet lives inside packages/spec/scripts/build-schemas.ts, which check:authorable-surface already runs.

The premises, re-measured — two reproduce, one does not

The card was filed by the dispatching seat, so every claim in it was re-measured here with its own control.

① z.toJSONSchema drops a refinement — REPRODUCES. 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. Lit control in the same run: z.string().min(1) does move the bytes ("minLength":1), so the instrument can see a projected constraint. Pinned in packages/spec/scripts/dropped-refinements.test.ts.

② TraceSamplingConfig.condition.anyOf[0] — REPRODUCES, byte for byte. The generated file reads {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}.

③ The consequence, as written — DOES NOT REPRODUCE at that slot. Measured against the live schema:

claim measured
the published anyOf[0] accepts {dialect:'cel'}, "which the runtime refuses" the runtime accepts it too. condition is z.union([z.record(z.string(), z.unknown()), ExpressionInputSchema]); the permissive record branch absorbs the object, so published and runtime agree here
"the string branch carries no non-empty constraint" it carries minLength: 1
"the runtime requires non-blank after trim" it does not — " " is accepted; only "" is refused, which minLength: 1 refuses as well

So the tracing slot is not a specimen of the gap. The gap itself is real and the card's own dedupe words point straight at the right file: ExpressionInputSchema carries the refinement "Expression requires at least one of source or ast", and the published packages/spec/json-schema/shared/ExpressionInput.json states only "required": ["dialect"]. {"dialect":"cel"} is accepted by the published file and refused by the runtime — the card's sentence, one file up from where it was written.

The census

Measured by the generator itself, on this branch, at zod 4.4.3:

reading value
refinement call sites in packages/spec/src/** (excluding *.test.ts) 126 — .refine( 48 · .superRefine( 75 · .check( 3
published schemas carrying at least one dropped refinement 240
dropped refinement sites on published schemas 688
sites that did reach the published file 0
sites with no JSON form on either side to compare 3

The fan-out between 126 and 688 is the point: one refinement on a shared schema lands on every published file that embeds it. Per namespace: ui 123 · api 251 · data 102 · system 81 · automation 46 · kernel 32 · ai 14 · shared 13 · security 10 · identity 9 · integration 1.

⚠️ The dispatch's dark probe counted .refine( in 21 files. That count included *.test.ts and, more importantly, did not look for .superRefine( — which is 75 of the 126 sites. The population is larger than the card implies, in the card's own direction.

The whole census is committed as packages/spec/dropped-refinements.baseline.json, keyed by published file, each entry naming the paths at which a rule is dropped.

The noise, in three places

  1. On the artifact. Each affected file under packages/spec/json-schema/** now carries x-dropped-refinements, naming its own sites — the sibling of the x-unprojectable-branches annotation [finding] Five filter operators ($gt/$gte/$lt/$lte/$between) reach NO published reference page — build-schemas.ts skips their whole schema over an unrepresentable z.date(), and the skip is silent #16431 already writes for the opposite direction. x- keywords are ignored by every validator, so the set of documents each schema accepts is byte-for-byte what it was.
  2. In the build log. Every gen:schema / check:authorable-surface run reports the accepted population in full, the same discipline as the never-published ledger above it.
  3. As a ratchet. A published schema that drops a refinement and is not in the ledger fails the generator; so does a ledger entry whose site list the build no longer observes, in either direction. The failure prints the corrected entry in full.

The detector is MEASURED, not asserted

collectDroppedRefinements does not trust the sentence above. For every node carrying a custom check it builds the same node without those checks — clone() recomputes the constraint bag from the check list — and compares the two projections byte for byte. A zod release that learns to project refinements therefore turns those sites projected and the ledger goes red asking to be emptied, instead of reading as current forever.

Two false readings were measured and closed while building it, both pinned:

  • clone() does not carry .describe() text (it lives in z.globalRegistry, keyed by instance), so without a meta copy every described node read as projected.
  • Walking a lazy node's _cachedInner memo reaches a second instance of the same graph, whose recursive $ref layout differs. All 80 sites the first build called projected were that, and none of them were about a refinement.

Proof it can fire, and proof it stays silent

A live ablation on this branch, both legs proven on disk rather than by exit code.

  • Mutated — one real .refine() added to TestAssertionSchema in packages/spec/src/qa/testing.zod.ts (a namespace with zero recorded drops). Marker grep on disk: 0 -> 1; anchor grep 1 -> 0. pnpm --filter @objectstack/spec gen:schema exits 1, naming four published schemas and the exact path under each:
 4 published schema(s) drop a refinement and are not declared in dropped-refinements.baseline.json:
     + qa/TestAssertion  (1 site(s))
         ROOT  (object)      [the generator prints this position in angle brackets; respelled here]
     + qa/TestScenario  (1 site(s))
         steps.element.assertions.element  (object)
     + qa/TestStep  (1 site(s))
         assertions.element  (object)
     + qa/TestSuite  (1 site(s))
         scenarios.element.steps.element.assertions.element  (object)
  • Restored — git checkout HEAD -- PATH; git hash-object equals the HEAD blob (198a4236e9c3e8f7a1c58c55dd4db7cbabc28c98) and git diff HEAD is empty for the target. The same command then exits 0 and prints the accepted population. The ablation script carries trap ... EXIT INT TERM with absolute paths, and treats an empty hash as a failure.
  • Silent — the whole qa/ namespace had zero entries before that mutation, and the unit suite asserts silence on a live refinement-free schema (AggregationFunction) and on a synthetic schema whose only constraints project.

What this PR deliberately does NOT do

⛔ It does not narrow any published shape. Teaching the projection to emit what a refinement constrains — propertyNames, not, minLength and friends cover a lot of them — or declaring the artifact a floor, both change a public contract. That is triage's item 2, and it stops here with this report.

⛔ It does not delete or weaken a refinement. The runtime rule is correct; it is the projection that is silent.

One boundary worth naming: the annotation reaches the JSON file and not content/docs/references/** — a full gen:docs on this branch produces a zero-line diff. Rendering it on the reference pages is a docs-surface change and was left out.

Verification

Every reading below is from commit 74b5af39e1, the final commit on this branch, with each exit code captured after a redirect and never through a pipe.

command exit
pnpm lint (eslint . --no-inline-config — the whole repo, no narrowing) 0
pnpm --filter @objectstack/spec exec vitest run --project local 0 — 487 files, 13900 tests
pnpm --filter @objectstack/spec typecheck (tsc --noEmit + scripts + test layers) 0
pnpm --filter @objectstack/spec check:generated 0 — all 15 generated artifacts up to date
pnpm --filter @objectstack/spec check:authorable-surface (the mode that runs this ratchet) 0
pnpm check:published-files · check:type-check-coverage · check:nul-bytes · check:merge-driver 0
check-adr-0087-registration · check-empty-changeset · check-changeset-no-major, each --base origin/main 0

The 66 families derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack were all run; 62 exit 0 and the four that could not be answered locally are named in the acceptance notes above and in the dev report on the card.

Two readings worth recording because they were NOT about this diff: check:published-files first exited 1 with "origin/main and HEAD have no merge base in this checkout" — a shallow clone whose graft floor had moved past the branch point. git fetch --deepen 300 restored the merge base to the recorded branch point and the gate then exits 0. And a control for every zero reading above: the control-character sweep grep -naP over the five changed files exits 1 (no match) while the same expression over a file carrying one ESC byte exits 0 and prints it.

Acceptance notes

  • Noticed, not filed — the annotation stops at the JSON tree. content/docs/references/** renders from packages/spec/json-schema/** but drops unknown x- keys, so a full gen:docs on this branch produces a zero-line diff. An author reading the reference page still sees nothing. Carrying it onto the page is a docs-surface change; nobody is mid-flight in that file today.
  • Noticed, not filed — aborting reads the check-level flag only. .refine(fn, { abort: true }) is visible; a ctx.addIssue({ fatal: true }) written inside a superRefine body is a property of the issue raised at parse time, not of the check the graph carries, so it reads false. It is a report detail and never the verdict — such a site is still a drop. Pinned both ways in the test file.
  • Noticed, not filed — the ledger's diff amplifies. One refinement on a shared schema lands on every published file that embeds it, so adding one .refine() can move several ledger entries at once (the ablation moved four). That is the true fan-out, and the gate prints every corrected entry in full, but a reviewer should expect the shape.
  • Measured, not a finding — three derived families cannot be answered from one worktree. check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt answer PREREQUISITE NOT MET (exit 3) because they sweep the BUILT output of all 81 workspace packages; a spec-closure build does not satisfy them and a full workspace build is CI's Build Core job. check:pm-dispatch-gates is still running at 540s and is reported NOT MEASURED, not red. All four are reported as absences, never as passes.

Patch round 2 — Test Core (1/6) was red, and it was this PR's

CI on 05cdeb2611 read 29 success, 3 expected skips, one failure: @objectstack/spec#test:repo, 36 tests down in scripts/build-schemas-check-mode.test.ts, every one of them an expect(status).toBe(0) that got a 1. The shard is green on main, so it was ours.

Why neither earlier round saw it. packages/spec declares two vitest projects and two turbo tasks: test is vitest run --project local, test:repo is vitest run --project repo. Both rounds verified with test alone. test:repo owns the 31 files that read outside the package — including every fixture that spawns the real generator — and CI runs both. The ratchet this PR adds lives in the generator, so repo was exactly the project that could see it and exactly the one never run.

Defect 1 — the census was one reading per schema-evaluation mode, not one reading

lazySchema() (src/shared/lazy-schema.ts) returns the real schema under OS_EAGER_SCHEMAS=1 and a Proxy over it otherwise. gen:schema and check:authorable-surface both export that flag; the check-mode fixtures deliberately spawn the generator without it, and say so in their header.

The walk keyed its visited set on the schema instance. So a sub-schema reached both directly and through a lazySchema() edge was one node to the eager walk and two to the lazy one. Measured on ui/View, whose list / listViews.valueType and form / formViews.valueType pairs each reach one schema by both routes: 11 dropped sites eager, 13 lazy. Six ledger entries then disagreed with the build in one mode and agreed in the other — the same generator, the same tree, two censuses.

The key is now the node's _zod internals, with the Proxy resolved to the internals its facade prototype-delegates to. Two keys were rejected on measurement, and both rejections are recorded in the code:

  • the instance — not mode-invariant, the defect above;
  • the def — over-collapses in the other direction. clone() with no argument hands a second instance the first's def object, so keying on it dropped …options[3].object.fields.valueType from system/ChangeSet and system/MigrationOperation, a reading the accepted ledger does not make. That attempt was measured and reverted, not shipped.

_zod is per instance where the def is not, so it moves nothing in eager mode and makes the lazy walk agree with it. The committed ledger is untouched — no entry added, none removed, no count flattened.

Defect 2 — the sandboxes never mounted the new ledger

Each fixture builds a temp package tree that copies scripts/ and symlinks src/, node_modules/, package.json, then seeds the committed artefacts the generator reads. dropped-refinements.baseline.json was never added to the five builders, so the generator refused on a missing ledger before reaching whatever each fixture was about. The mount is now a list of committed package-root ledgers, so the next one is one entry rather than a sixth call to remember.

The pin, and its ablation

scripts/dropped-refinements.test.ts gains three cases: a shared sub-schema reached by two routes is one site, the same holds across a lazySchema() edge, and — the lit control — two genuinely distinct nodes carrying the same rule are still two sites.

⚠️ The first draft of that pin asserted nothing, and the ablation is what caught it: with the refinement one level down, the property schema is the same instance by either route, so the walk deduped on that alone and the pin passed against the very defect it was written for. Moving the rule onto the shared node makes identity the thing under test. Ablating the fix now reads [ 'direct', 'viaLazy' ] where it expects [ 'direct' ]; restore is git checkout HEAD -- PATH proven by an empty git diff HEAD, under a trap ... EXIT INT TERM with absolute paths.

Verification — this round, at 6eeebd726d

Exit codes captured into a file and read from $? after the redirect, never through a pipe.

command before after
pnpm --filter @objectstack/spec test:repo (project repo) 1 — 36 failed / 500 passed 0 — 536 passed, 31 files
pnpm --filter @objectstack/spec test (project local) not re-measured this round 0 — 487 files, 13987 tests
pnpm --filter @objectstack/spec check:generated not re-measured this round 0 — all 15 artefacts current
pnpm --filter @objectstack/spec check:authorable-surface not re-measured this round 0 — 737 sites / 243 schemas
the same generator without OS_EAGER_SCHEMAS, tree-wide not measured before 0 — 737 / 243, identical
pnpm --filter @objectstack/spec typecheck — 0
pnpm lint (eslint . --no-inline-config, whole repo, no narrowing) — 0
check:nul-bytes · check:cross-package-test-inputs · check:test-source-alias · check:type-check-coverage — 0

The two mode readings are the load-bearing pair: 737 sites across 243 published schemas in both modes, where before the fix the lazy mode disagreed with the ledger on six entries.

⚠️ Declared narrowing. test:repo was not run as a single process: this container kills a foreground command at roughly ten minutes and that project takes about sixteen in CI. It was run as seven foreground chunks of the same project and config — one for the 30 other files (451 tests), six covering all 14 describe blocks of build-schemas-check-mode.test.ts (85 tests). 451 + 85 = 536, which is the count CI reports for the project. No chunk was skipped and none reported a failure.

⚠️ NOT MEASURED, reported as an absence and not as a pass: check:type-check-debt answers PREREQUISITE NOT MET (exit 3, its own code for "nothing was measured") because its --re-measure half needs the built dist of 30 workspace dependencies; its coverage half, check:type-check-coverage, exits 0. A full workspace build is CI's job, and the gate's own text says so.

⚠️ The round-1 verification table above this section is a reading from 74b5af39e1 and is left as written. Its census numbers — 240 schemas / 688 sites — are that commit's; the 243 / 737 here is the same measurement after a0127cfe26 recorded the 49 sites a sibling landing added.

Acceptance notes — patch round 2

  • Noticed, not filed — a new package-root ledger is still a manual mount. The list makes it one line instead of five, but nothing mechanically holds the list equal to the set of ledgers the generator actually reads; the next one is caught by the same 36 red fixtures rather than by a gate that names it. Naming it would need the generator's read set derived statically. Nobody is mid-flight in these builders today.
  • Noticed, not filed — the census's mode-invariance is pinned on a synthetic graph, not on the live one. The live discriminator would be spawning the generator twice per run, once per mode, which doubles the slowest gate in the package to pin a property the unit pin already fails on.

Generated by Claude Code


Generated by Claude Code

… now makes a noise

`z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a
`.refine()` / `.superRefine()` is enforced by the runtime and absent from the
`json-schema/` tree that ships inside `@objectstack/spec` — a published file
WIDER than the Zod type it came from, in the direction where an author's (or an
AI's) validator says yes and the platform then says no.

Census, measured by the generator itself on zod 4.4.3: 688 refinement sites
across 240 published schemas, none of which projected anything, growing from 126
refinement call sites in `packages/spec/src/**`.

Nothing about what the schemas accept changes. Each affected file now carries an
`x-dropped-refinements` annotation naming its own sites (`x-` keywords are
ignored by every validator), the generator reports the population in full on
every run, and `dropped-refinements.baseline.json` refuses to let it grow in
silence. The detector MEASURES each drop per instance — project the node,
project it again with its custom checks removed, compare bytes — rather than
asserting it, so a zod release that learns to project refinements turns the
ledger red instead of reading as current forever.

Narrowing the published shape to match the Zod type is a public-contract change
and is deliberately not done here.

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

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 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 2085be2b2d8769c6227167bb3525f4fa72b9a486 → packageMentionDocs.

…padding refusal added

Merging origin/main brings `fix(spec)!: refuse a padded groupByField on kanban,
gantt and timeline` (12bb672) into this branch. That commit put a
`superRefine` on `KanbanConfigSchema.groupByField`,
`GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField`, and
`z.toJSONSchema()` has no arm for a `custom` check — so three new published
schemas enter the dropped-refinement population and sixteen existing entries
grow a site wherever one of those three configs is reachable.

This is the ratchet reporting a real new gap, not a false one: every added site
is a `gantt|kanban|timeline.groupByField` path and traces to that one commit.
Nothing shrinks, no refinement is touched, no published shape is narrowed.

  entries 240 -> 243, sites 688 -> 737 (+49, 0 removed)

The ledger is hand-edited on purpose and has no `gen:` script — admitting a gap
is a decision, not a command. The sites here are the ones
`check:authorable-surface` printed as "the corrected entries, in full", applied
under a script that refuses any delta that is not a pure addition of a
groupByField site.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
…schema-evaluation mode

Two defects, both surfaced by `@objectstack/spec#test:repo` — the vitest
project neither round of this card ran, and the one CI runs alongside
`test`.

1. The walk's visited set was keyed on the schema INSTANCE. `lazySchema()`
   returns the real schema under `OS_EAGER_SCHEMAS=1` (how `gen:schema` and
   `check:authorable-surface` run) and a Proxy over it otherwise, so a
   sub-schema reached both directly and through a `lazySchema()` edge was one
   node to the eager walk and two to the lazy one. The census — and so the
   ledger comparison — then depended on which mode the generator was spawned
   in: `ui/View` measured 11 dropped sites eager, 13 lazy. Keyed on the zod
   `def` instead, which the Proxy's `_zod` facade prototype-delegates rather
   than copies, both modes read the same. The module already stated the intent
   this restores: once per published file that reaches it.

2. The check-mode fixtures mount the committed package-root ledgers into each
   sandbox, and the dropped-refinement ledger was never added to the five
   builders. Every fixture expecting exit 0 got a 1 about a missing artifact
   instead of about its own subject. The mount is now a list, so the next
   ledger is one entry rather than a sixth call to remember.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
… or the def

Narrows the previous commit's identity. `_zod` is per instance where the def is
not: `clone()` with no argument hands a SECOND instance the FIRST's def, so
keying on the def collapsed two nodes the accepted census counts separately and
dropped `…options[3].object.fields.valueType` from `system/ChangeSet` and
`system/MigrationOperation`. Keying on `_zod` — with `lazySchema()`'s Proxy
facade resolved to the internals it prototype-delegates to — moves nothing in
eager mode and makes the lazy walk agree with it.

Measured tree-wide, both modes, ledger untouched: 737 sites across 243
published schemas, exit 0.

Pinned with its lit control: a shared sub-schema reached by two routes is one
site, the same holds across a `lazySchema()` edge, and two genuinely distinct
nodes carrying the same rule are still two sites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
…identity decides it

The first draft asserted nothing: with the refinement one level down, the
property schema is the same INSTANCE by either route, so the walk deduped on
that alone and the pin passed against the very defect it was written for —
measured by ablating the fix and watching it stay green. Moving the rule onto
the shared node makes identity the thing under test. Ablation now reads
`[ 'direct', 'viaLazy' ]` where it expects `[ 'direct' ]`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
@os-litant
os-litant marked this pull request as ready for review September 17, 2026 20:50
@os-litant
os-litant added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit a49e8ae Sep 17, 2026
44 checks passed
@os-litant
os-litant deleted the claude/issue-18670-refinement-projection-census branch September 17, 2026 21:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ling of the QueryAST (objectstack-ai#18704)

Fixes objectstack-ai#16066

Clause-②: yes (widening)

Authored by Claude Code, session
https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

The `findData` slot declared one query dialect and accepted two.
`FindDataRequestSchema.query` was `QuerySchema` — the canonical QueryAST
— while the shipped door also folded `$filter` / `$top` / `$skip` /
`$orderby` / `$select` / `$expand`, the plural `filters` and the bare
aliases `filter` / `select` / `sort` / `skip` / `populate`, from a table
that lived module-private inside `@objectstack/metadata-protocol` and
whose own comment called them "the wire-only spellings no schema
declares". Two dialects, one slot, one of them declared: unverifiable at
build time, unrejected at runtime.

Director seat ruling, decision batch objectstack-ai#129 item 4 (2026-09-13, comment
5651810333). Maintainer, verbatim: 「你是项目总监,9306
这种为什么你不直接决策呢?而且不是说要以协议为准吗?还需要立卡问 spec 多次一举啊。这个也需要更新skills。其他同意。」 —
「其他同意」 covers this card.

## What landed

**1. `packages/spec` declares the transport dialect, once.** In
`src/data/data-engine.zod.ts`, beside `RPC_QUERY_ALIAS_SLOTS` — "the ONE
place the alias to canonical mapping is declared":

| export | what it is |
|---|---|
| `QueryTransportParamsSchema` + `QueryTransportParams` /
`QueryTransportParamsParsed` | every spelling the tables name that is
not itself a QueryAST key, each carrying the value set the canonical
slot serves |
| `QUERY_TRANSPORT_ALIAS_SLOTS` | `RPC_QUERY_ALIAS_SLOTS` extended with
the transport-only spellings (`filters`/`$filter` onto `where`,
`$expand` onto `expand`) |
| `QUERY_TRANSPORT_DOLLAR_ALIASES` | the `$`-to-bare pairs that fold in
two hops (`$top` onto `top` onto `limit`) |
| `QUERY_TRANSPORT_DOLLAR_PARAMS` | the `$` set a boundary quotes when
it refuses an undeclared one |
| `QueryWithTransportSchema` + `QueryWithTransport` /
`QueryWithTransportParsed` | the query slot itself: input is the AST or
its transport spelling, output is the AST plus the `count` flag |

9 exports added, 0 removed (`api-surface/data.json`). `top` is the one
alias the tables name that the transport schema does NOT declare:
`BaseQuerySchema` already carries it beside `limit`, and re-declaring it
would widen a canonical member rather than a transport one.

**2. `FindDataRequestSchema.query` is that slot.** `z.input` admits the
canonical AST, the transport spelling, or a bag carrying both.
`z.output` is `QueryAST & { count?: boolean }`, and it is CONSTRUCTED
rather than asserted: the fold's result is parsed by the AST schema and
that parse's result is what leaves the transform. Prime Directive objectstack-ai#12 is
kept the way the ruling specifies — the transport form is the FLATTENED
SPELLING of the same AST with a 1:1 alias table, never a second
semantics — so `QuerySchema` itself is untouched and still drops a `$`
key as unknown. The hint table in `metadata-protocol`
(`QUERY_PARAM_NEAR_MISS`) is untouched and still accepts nothing as
input.

**3. `metadata-protocol` folds by the spec export.**
`WIRE_QUERY_ALIAS_SLOTS` / `WIRE_DOLLAR_ALIASES` are gone; the
`UNSUPPORTED_QUERY_PARAM` refusal now quotes
`QUERY_TRANSPORT_DOLLAR_PARAMS` instead of a hand-copied sentence.

**4. `scripts/check-filter-alias-parity.mjs` follows the hoist.** Its
own header named this hoist as the open option and said the script "can
be deleted rather than migrated" once BOTH sides derive. Only one side
does: `packages/rest`'s `FILTER_SLOT_QUERY_PARAMS` still names `filters`
/ `$filter` literally, so the drift the gate exists for is still
reachable and the gate was **re-pointed, not deleted** — its readers now
read the spec file. Self-test green (7 batteries), gate green over the
real tree: `4 transport spelling(s) of the 'where' slot, identical on
both sides`.

## The dropped-refinement ledger, declared

⚠️ Added by the `domain:spec` seat after the fix round — `os-dev.md:56`
reserves this body to the PR-open write, so the round named the wording
and the seat writes it.

This PR introduces two NEW published schemas that drop a refinement, and
moves an existing entry:

| ledger entry | sites |
|---|---|
| `data/QueryTransportParams` | 4, added |
| `data/QueryWithTransport` | 4, added |
| `api/FindDataRequest` | re-sited from the single `query.where.lazy` to
the four `query.in.*` the new query slot produces |

Header totals move **243 / 737 to 245 / 748**, taken from `gen:schema`'s
own printed line — ⛔ not retyped and ⛔ not computed by arithmetic over
the file. `refinementSitesThatDidProject` (0) and
`refinementSitesWithNoJsonFormToCompare` (3) are unmoved.

⛔ **No `.refine()` or `.superRefine()` was touched, added or weakened.**
The ledger is a visibility ratchet; the published JSON Schema stays
exactly as wide as it was. The gate's own words: 「The refinement itself
is correct; ⛔ do not delete or weaken it to make this line go away.」

⭐ **Why this only appeared now, and it was not this PR going wrong.**
The gate landed on `main` via objectstack-ai#18729 (`a49e8ae963`,
2026-09-17T20:51:30Z) — twenty minutes AFTER this branch last merged
main at `fa423f55b0e` (20:31:14Z). The ratchet is absent from the branch
tree at every head it has had, so no build of this head could ever have
run it. CI builds the **merge ref**, not the branch head, so the run
before this one was computed against a `main` without the gate and the
run after against a `main` with it. The failing run proved it itself: it
printed `- query.where.lazy` for `api/FindDataRequest`, a site that
exists only in main's copy of a file this branch head does not contain.
⇒ what flipped the checks red across a markdown-only commit was the BASE
moving, not the diff. Full measurement in the seat's comment on this PR.

**Evidence at `53591e48be2a`:** spec `build` 0; `test` (project `local`)
488 files / 14113 tests; `test:repo` (project `repo`) 31 files / 537
tests; `typecheck` 0; `check:generated` 0 (all 15 artifacts up to date);
`check:authorable-surface` 0. The ledger pin
`scripts/dropped-refinements.test.ts` was run on its own — 27 tests,
exit 0, including 「names at least one site per entry, and its header
totals match its body」.
## One semantics is a claim about VALUES, and that is what this round
fixes

The first two rounds made the KEYS 1:1 and left the VALUES apart: the
transport arm admitted value grammars the canonical arm of the same
schema refused, so one slot had two acceptance grammars selected by
spelling — and the output type was a cast the transform never honoured.
Measured at `5c072bbe63` with `QueryWithTransportSchema.safeParse`, and
re-measured at this head with the same probe:

| input | at `5c072bbe63` | now |
|---|---|---|
| `{$orderby: 'name'}` | OK, `{orderBy: 'name'}` | refused |
| `{orderBy: 'name'}` | refused | refused |
| `{$top: 'abc'}` | OK, `{limit: 'abc'}` | refused, one issue at `limit`
naming `'$top'` |
| `{$top: ''}` | OK, `{limit: ''}` | refused |
| `{$top: '50'}` | OK, `{limit: 50}` | OK, `{limit: 50}` |
| `{limit: '50'}` | refused | refused |
| `{$filter: 'not json'}` | OK, `{where: 'not json'}` | refused |
| `{$filter: ['status','=','open']}` | OK, `{where:
['status','=','open']}` | OK, `{where: {status: 'open'}}` |
| `{where: ['status','=','open']}` | refused | OK, `{where: {status:
'open'}}` |
| `{$count: true}` | OK, `count` undeclared on the output | OK, `count`
declared on the output |
| `{where: {a:1}, $filter: {b:2}}` | OK, **output still carried
`$filter`** | refused, one issue at `where` naming `'$filter'` |

Every value shape a transport member admits now either LOWERS to the
canonical member's declared shape or FAILS the parse. What lowers: a
stringly-typed `$top` / `$skip`, a comma list on `$select` /
`$searchFields` / `$expand`, a `{field: direction}` sort record, a
relation-name list on `populate`, `'true'` / `'false'` on `$count`, and
the input-only `FilterArray` sugar on every spelling of the filter slot
— `where` included.

**The `FilterArray` route is a deliberate deviation from the review's
letter, and objectstack-ai#5158 is why.** The review prescribed declaring the ObjectQL
array on the canonical member in `query.zod.ts`. That is rejected option
A of maintainer ruling C on objectstack-ai#5158 — 「widen `where` to accept the array
dialect, so every driver and transport maintains two compilers forever」
— and `packages/spec/src/data/filter-array-declaration.test.ts` pins the
negative half explicitly: *"a query `where` does NOT accept the array
dialect … a future 'helpful' widening of the protocol face turns this
red"*. So the array is declared on the TRANSPORT-AWARE SLOT, on every
spelling of the filter slot including canonical `where`, and lowered
through `parseFilterAST` — ruling C's own sink. Both halves are
measured: the four spellings agree (`§3` of the spec suite), and
`QuerySchema.where` still refuses the array (that file is green, 13
cases).

**What is REFUSED at the parse**, because lowering it would mean parsing
the spec must not do:

- a non-numeric `$top` / `$skip` — the card's own defect class: `$top:
'abc'` passed POST validation and reached the engine as `limit: null`,
an UNBOUNDED read under a `200`; `$top: ''` as `limit: 0`;
- a JSON-encoded `$filter` string;
- an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`,
`'-created_at'`, `['name']`) — the record and `SortNode[]` forms are
unaffected. This is NOT the only refusal that takes a body from `200` to
`400`. Five shapes were read off the forwarded ORIGINAL body and SERVED
CORRECTLY before this change, and now answer `400 VALIDATION_FAILED` at
the ingress: `{$orderby: 'name desc'}`, `{sort: '-created_at'}`,
`{$orderby: ['name']}`, `{$filter: '{"status":"open"}'}`, and that same
JSON string on `filters` / `filter`. Two more went `200` to `400` for
the opposite reason — `$top: 'abc'` reached the engine as an unbounded
read and `$top: ''` as `limit: 0`, a wrong answer under a `200`. The
changeset carries each of the five with its FROM to TO. The door parses
those strings and the spec must not own a second parser for them, and
refusing is what makes `sort` and canonical `orderBy` accept one set
without widening the canonical arm. The GET querystring path and
in-process `findData` are unchanged — neither parses through this
schema;
- a filter array no lowering can express, such as the INFIX join
`[condA, 'and', condB]`. `isFilterAST` refuses it, `parseFilterAST`
lowers it to nothing, and the engine already answered `400` for it
naming the prefix form. `rest-server-repeated-filter-param.test.ts` §3
pinned that body as forwarded with a MOCKED `findData`, so nothing
downstream ever ran; that case now uses the prefix form `['and', condA,
condB]` and a new case pins the infix one as refused at the ingress;
- a `$count` that is neither the boolean nor `'true'` / `'false'`;
- a CONFLICT — two spellings of one slot with different values —
reported at the canonical path via `aliasConflictIssue`, quoting the
spelling the caller actually wrote (`$orderby`, not `orderBy`), exactly
as `foldRpcQueryOptions` does in the same file. The previous shape left
the conflict UNFOLDED, which is how `$filter` stayed on a parsed output
that claimed to be an AST.

None of these narrows a DECLARED surface: no such value shape was ever
declared. What five of them DO narrow is what the door SERVES — see the
changeset, which carries the FROM to TO for each. The rest only narrow
how far an unservable body travels before it is refused.

**One cast remains, and it restates the INPUT only.** `QuerySchema` is
annotated as a `z.ZodType` carrying `QueryAST` and `QueryInput` as its
two type arguments, for its recursion, so `.extend` on it is reachable
only through a cast that erases both type arguments; without restating
the input, `z.input` of this slot would admit `$sort` and a query with
no `object`. The OUTPUT type is inferred from the transform's return
type, which is the return type of the AST schema's own `safeParse` —
nothing asserts what this schema emits. `§5` of the spec suite pins both
halves at the type level.

⚠️ **Why the fold parses in the transform instead of
`.transform(fold).pipe(…)`.** Same construction, measured consequence:
`packages/spec/scripts/build-schemas.ts` publishes a schema's OUTPUT
shape whenever that shape has a JSON form and falls back to the INPUT
shape only when it does not. Adding the pipe gives the output a JSON
form, so `data/QueryWithTransport.json` starts publishing the canonical
AST and all 32 transport keys DISAPPEAR from
`authorable-surface/data.json` — refused by that file's own deletion
gate, with `gen:schema` exiting 1. The authorable surface of this slot
is the transport vocabulary, so the parse happens one level in.

## Ruling item 4 — the two things the card never measured

**`getData` / the `*Many` siblings do NOT carry the same split**, so
there is nothing to declare the same way:

- `getData(request)` takes `id` / `select` / `expand` directly and has
no `query` slot at all; its body reads `request.select` /
`request.expand` and folds nothing (`protocol.ts` `async getData`).
- `UpdateManyRequestSchema` takes `records[]`, `DeleteManyRequestSchema`
takes `ids[]` (`packages/spec/src/api/batch.zod.ts`). Neither carries a
query.

**No caller outside `packages/rest` speaks a spelling the table does not
name.** Census over `packages` + `examples` + `apps`, excluding
`packages/rest`, tests, `dist` and the driver packages (whose
`$skip`/`$limit`/`$match` are MongoDB aggregation-pipeline stages, a
different namespace): the transport `$` keys in use are `$top`,
`$filter`, `$skip`, `$select`, `$orderby`, `$expand`, `$count`,
`$search`, `$searchFields` — all nine named by the table.

An undeclared `$` spelling was, and stays, refused loudly: `400
UNSUPPORTED_QUERY_PARAM`, pinned in §4 of the new suite and at the type
level.

## The generated-docs regression is closed

`expand` is RECURSIVE, so `z.toJSONSchema` hoists it into `$defs` and
renders the property as a bare `$ref` — which carries no sibling
`description`. This slot publishes its INPUT shape (it is a transform),
and in that direction the row rendered with an EMPTY description cell.
Re-describing the member on the extended shape, with the text READ from
`QuerySchema` rather than re-typed, puts it back. Measured with `grep -c
'^| \*\*expand\*\* .*| |$'`, exit code read from `$?` after a redirect:

| file | `origin/main` | at `5c072bbe63` | now |
|---|---|---|---|
| `content/docs/references/api/protocol.mdx` | 0 | 1 | **0** |
| `content/docs/references/data/data-engine.mdx` | 5 | 8 | **5** |

The `data-engine.mdx` reading is the same command's hitting control for
the `protocol.mdx` zero.

## The byte-equality pin, and it goes red


`packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts`
§1 holds the two exported tables, and the refusal sentence, against the
values the module-private ones RESOLVED TO on `origin/main` at
`6dfa3ea772` — a frozen BEFORE reading, transcribed from that tree, not
a restatement to be kept in sync. §2 drives every alias the tables
declare through the REAL normalizer and asserts the option bag
`engine.find` receives equals the canonical spelling's, with the COUNT
call and the response envelope in the comparison and with an explicit
guard that the canonical leg SERVED, so no pair can agree by both being
refused. Both ablation legs and their restore are recorded in the round
that landed it; 24/24 green at this head.

**This round's ablation**, one-shot, on the fold itself.
`packages/spec/src/data/query-transport.test.ts` imports
`./data-engine.zod` RELATIVELY, so its verdict is a function of source,
not of `dist` — no rebuild mediates it:

| leg | on-disk proof | result |
|---|---|---|
| mutate: the fold reverted to its `5c072bbe63` body | marker `which is
not a number.` 1 → 0 occurrences in the source; blob `f388a53f` →
`e46141954` | **20 failed / 58**, exit 1 |
| restore: `git checkout HEAD -- PATH` | blob back to `f388a53f`, `git
diff HEAD` on the target exits 0 | **58 passed / 58**, exit 0 |

## Tests

- `packages/spec/src/data/query-transport.test.ts` — 58 cases. §1
derives the declared key set from the two tables MINUS `QuerySchema`'s
own shape (so `count` survives the subtraction for a stated reason), §2
every alias to its canonical slot, **§3 the totality of the fold — every
assertion reads the OUTPUT**, §4 `QuerySchema` still dropping `$filter`
/ `$top`, §5 the declared input and output types. The previous §3
asserted `.success` alone, which is exactly how every non-AST output
above went unmeasured.
-
`packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts`
— 24/24, plus `protocol.query-param-arity` /
`protocol.orderby-vocabulary` / `protocol.malformed-filter` /
`protocol.count-opt-out`: 102 passed / 5 files.
- `packages/rest` — `rest-server-canonical-query-ast` /
`rest-server-repeated-filter-param` / `list-view-grouping-query-door` /
`rest-server-closed-query-params` / `rest-server-query-multiplicity`: 25
+ 147 across the set, green after the §3 update above.
- `packages/spec/src/data/filter-array-declaration.test.ts` — 13/13, the
objectstack-ai#5158 negative half included.

Full suites and the lint union, all at this branch's head `e695f895c8`
(the final commit), each exit code captured from `$?` after a redirect:

| run | result |
|---|---|
| `pnpm --filter @objectstack/spec typecheck` | exit 0 |
| `pnpm --filter @objectstack/spec test` | **486 files / 13926 passed**,
exit 0 |
| `pnpm --filter @objectstack/metadata-protocol test` | **179 passed + 3
skipped / 182 files · 2572 passed, 19 skipped**, exit 0 |
| `pnpm --filter @objectstack/rest test` | **193 files / 3230 passed, 1
skipped**, exit 0 |
| `eslint . --no-inline-config --format json` | **6820 files examined, 0
errors, 0 warnings**, exit 0 — the whole union, not a narrowing |

Gates run locally, each exit code captured from `$?` after a redirect,
never through a pipe: `check:generated` (15/15), `check:docs`,
`check:api-surface`, `check:export-origins`, `check:declaration-map`,
`check:strictness-ledger`, `check:authorable-surface`,
`check:filter-alias-parity`, `check:nul-bytes`,
`check:spec-parsed-alias`, `check:published-files`,
`check:pm-widening-tells`, `check:query-options-erasure`,
`check:objectql-double-limit`, `check:where-matcher`,
`check:parse-guard`, `check:test-source-alias`,
`check:cross-package-test-inputs`, `check:type-check-coverage`,
`check:closing-keyword-parity`, `check:ci-filter-parity`,
`check:pm-dispatch-gates`, `check-changeset-no-major --base
origin/main`, `check-adr-0087-registration --base origin/main` — all
exit 0.

`scripts/pm/check-clause2-carriers.mjs --pair 18704` exits **4**:
`needs:contract-review` is owed a re-hang because the head moved past
the review that cleared it. Reported, not acted on — hanging or clearing
a review gate is a seat's act.

**NOT MEASURED, stated rather than implied:** `check:type-check-debt`
returned its `PREREQUISITE NOT MET` exit 3 (the whole `packages/*`
closure is not built in this checkout) — nothing was measured, and it is
neither a pass nor a finding. The remaining families of the 127
`dispatch-gates` derives for this change set are CI's run; that
derivation also warns the tree is behind `origin/main` and that 13 of
the files it derives from moved in that range, so CI will run families
this list does not name. The GET querystring path end to end, and
objectui's runtime, were not exercised.

## Acceptance notes

Noted, not filed — nothing here is a reproducible defect, a contract
violation, or a metadata-authoring trap:

- `ODataQuerySchema` (`packages/spec/src/api/odata.zod.ts`) declares
`$format` and `$apply`, which the `findData` door refuses with
`UNSUPPORTED_QUERY_PARAM`. It is a separate surface and no caller routes
it into this door. Who would meet it: a future OData adapter card.
- `getData`'s implementation signature accepts `select` / `expand` as
`string | string[]` while `GetDataRequestSchema` declares arrays only.
Pre-existing, different axis. Who would meet it: whoever next touches
the single-record read path.
- `packages/rest`'s `FILTER_SLOT_QUERY_PARAMS` could now derive all four
filter spellings from `QUERY_TRANSPORT_ALIAS_SLOTS`, which would make
`check:filter-alias-parity` deletable exactly as its header prescribes.
Left alone: it moves runtime code in a package this card does not land
in. Who would meet it: the next `packages/rest` query-ingress card.
- `{top: 6, $top: 5}` folds to `limit: 6` and discards the `5` without a
diagnostic, in the spec fold and at the door alike. Byte-equal to before
and now stated in the table's docstring rather than raised, because
raising it here and not at the door would make a POST body and a GET
querystring answer the same request differently. Who would meet it: the
next card that touches the door's `$`-to-bare hop.
- The transport members still accept input sugar the canonical member
does not (`$select: 'a,b'` against `fields`, `$top: '50'` against
`limit`). That asymmetry is the transport/canonical distinction itself —
the querystring carries strings — and every such shape lowers, so the
OUTPUT grammar is one. Widening the canonical members to match would
declare the looser grammar rather than close it. Who would meet it:
nobody, unless a future card moves the GET querystring path through this
schema.

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

---------

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

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

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`」.

## 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 46559f6   cases=12 documents=6027 accepted=1873 refused=4154
this head   eae168e   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`
现在一条缺口都不剩。

**为什么改** —— 裁决(批次 objectstack-ai#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](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
…nance instead of a liveness test; the reader accepts it and C9 keeps one red (objectstack-ai#19502)

Fixes objectstack-ai#19240
Clause-②: yes

`Clause-②: yes` — the claim reader's accept set widens (a cross-login
`Release:` carrying provenance now retracts) and C9's judged set narrows
to a bare cross-login `Claim:`; a `.claude/**` surface ⇒ Tier S, the
seat lands it on its `## Contract review` PASS + `--pair` 0. This PR
stays draft.

## What lands — ruling 5754797404, shape A, executed as ruled

**The claim HANDOVER protocol.** A card whose claimant is unreachable
(token exhausted, session ended, identity retired) is taken over by a
new session in ONE comment, and the claim reader accepts that comment —
no liveness heuristic anywhere: the human's word, copied with
provenance, is the permission.

| # | Surface | Change | Net lines |
|---|---|---|---|
| 1 | `scripts/pm/check-clause2-carriers.mjs` | `claimRetractions` gains
the HANDOVER arm; `CLAIM_RETRACTION_RULE`, `CLAIM_HANDOVER_RULE`,
`CLAIM_HANDOVER_REMEDY` rewritten; C9 keeps one red and lists refused
handover attempts; self-tests both sides (1075 → 1091 cases) | +174 /
−54 = **+120** (the claim's budget, exactly) |
| 2 | `.claude/skills/pm-dispatch/SKILL.md` | :177 · :472 · :492 aligned
in place; the nine liveness-heuristic bullets (:493–:501 at base)
replaced by five handover bullets | 813 → **809** (net −4; ceiling 813,
headroom 4) |
| 3 | `.claude/skills/pm-dispatch/references/core-rules.md` | the two
twins (:110, :111) rewritten in place | 151 → **151** (net 0) |
| 4 | `.claude/agents/os-dev.md` | :94 in place: every compilable step
is pushed; a handover reads the remote branch's last sha | 403 → **403**
(net 0) |
| 5 | `scripts/pm/check-half-states.mjs` | **untouched** — measured:
`grep -n 'author !== '` → 0 hits; its `Release:` readers (H47
`latestMarkedComment` / `releaseAnswersClaim`) compare comment ORDER,
never authors, so it carries no copy of the retraction rule and imports
nothing from the clause-② reader | 0 |

Base `32b5831`, `origin/main` merged once at `d00692f` (PR objectstack-ai#19462 had
not landed at 2026-09-21T04:1xZ — SKILL.md ceiling stays 813, no region
overlap). Every line ≤ 120 bytes; `check:pm-skill-ratchet`,
`check:pm-skill-id-lint`, `check:pm-governed-prose`,
`check:agent-model-declared`, `check:nul-bytes` all exit 0 on the edited
files.

## 1. The reader

### The HANDOVER arm of `claimRetractions` (the one accept-set widening)

A `Release:` comment by a **different login** retracts an earlier claim
when, and only when:

- (a) its `Release:` **line** (the first line of the body that
`markerMatches(RELEASE_COMMENT_MARKER, line)` reads — the sibling's ONE
reading, applied per line, so `**Release:**` and `` `Release:` `` read
and `- Release:` does not) names the retracted claim's **comment id**
(digit-bounded) **and** its **session id** (token-bounded; the claim's
`Session:` line first, else the first `session_…` token in the claim
body — a claim with none cannot be named, fail closed);
- (b) the comment carries the three provenance fields of SKILL.md's 出处三件
line 「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」, each with a
**non-empty** value.

Missing any one piece ⇒ NOT a retraction, state unchanged. ⛔ No liveness
test: the earlier claimant's later comments are irrelevant (pinned).
Same-login retractions: byte-for-byte the old behaviour (no id, no
session, no provenance needed).

**Pinned key spellings** (`HANDOVER_PROVENANCE_KEYS = ['谁的指令', '原话',
'在哪说']`, exactly the :149 vocabulary — ⛔ no fourth key, ⛔ no synonym). A
field is: the key · optional decoration (`*`, `_`, backticks) · an
optional parenthetical `(…)` / `(…)` · a colon (ASCII `:` or fullwidth
`:` — indistinguishable on the page, pinned equal) · the value = the
rest of that line up to the next key, or, when that is blank, the
blockquote (`>` lines) under the key. Whitespace, `>`, decoration and
separator punctuation alone are an EMPTY value (pinned per key).

The two live specimens, both replayed verbatim in the self-test:

- 5754797404 (inline paragraph): `**出处三件** —
**谁的指令**:维护者,在本席(…)会话内的三个真实用户轮次。**在哪说**:本席会话聊天,在评论
5754717208(2026-09-21T02:44Z)之后、本条之前的连续三轮。**原话**(逐字,⛔ 未翻译、未润色):`
followed by the blockquoted turns.
- 5754717208 (line per field): `**出处三件**——` / `**谁的指令**:维护者(本仓
maintainer,…)。` / `**在哪说**:本会话聊天内,…。` / `**原话**(逐字,⛔ 未翻译、未润色):` followed
by the blockquoted turns.

### C9 keeps exactly one red

`claimHandovers` is unchanged in its walk: it reads the LIVE claims
through the same `claimRetractions` map, so a handover comment (①
provenance `Release:` naming the holder's claim + ③ new `Claim:` with
`Branch:`/`Clause-②:` in the SAME comment) leaves one author holding ⇒
no row, no note, and the new `Claim:` is the governing claim on the
`--pair` path (a comment is not later than itself, so it cannot retract
its own claim — pinned). The one red left: a cross-login `Claim:` with
NO `Release:` at all for the earlier claim — a real claim-jump.
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` stays; its gating now applies to
that narrowed red only (it is read at the same place as before).

**Loud refusal, not silent red:** a cross-login `Release:` that TRIED to
hand over a live claim (names its id or session id, or carries a
provenance field) and did not is listed in the C9 sentence with its
reason — `missing 在哪说`, `the comment id is not on its `Release:` line`,
`the session id is not on its `Release:` line`, `the claim carries no
session id to name`. A bare `Release:` by another login (a seat
releasing its own claim) is not an attempt and is not listed — the first
draft listed those and the `--pair 19373` row named os-steve's own two
releases as "refused handovers" of os-bill's claim, which was noise;
narrowed.

**The remedy sentence** (`CLAIM_HANDOVER_REMEDY`) prescribes the
four-item handover comment and prints SKILL.md's handover sentence
**verbatim** (`CLAIM_HANDOVER_SENTENCE_LINES` = the five 认领 bullets,
byte for byte), citing the 出处三件 line as its source. The old remedy words
「the HOLDER posts `Release:` … the TAKER posts nothing until then … ⛔
never a `Release:` on the holder's behalf」 are gone; ② (assignee swap)
and ④ (the sha record) are stated as the seat's acts, unread by the
reader.

### Self-tests (beside the existing retraction and C9 cases, ⛔ not at
`selfTest()`'s tail; floor unchanged)

Retraction battery: ⭐ a cross-login provenance `Release:` naming id +
session is accepted — state `declared`, the handover's own `Claim:`
governs, the record says "a DIFFERENT login … HANDOVER" · ⛔ missing any
one field, or a key with an empty value ⇒ refused, one case per key each
way, the missing key named · ⛔ id without session / session without id /
both in prose under a bare `Release:` line / the three fields with no
`Release:` line at all ⇒ refused · ⭐ NO liveness test: the earlier
claimant commenting after the handover changes nothing · ⭐ both live
specimens' spellings read, and a fullwidth colon reads as the ASCII one
· ⛔ same-login `Release:` still needs nothing (arm untouched); a claim
with no session id cannot be handed over · ⛔ item ④ absent still
retracts (the seat's act, not the reader's gate) · the printed rule
names both arms, the three keys, the source line and the absent liveness
test.

C9 battery: ⭐ the handover comment clears C9 (no row, no note) · the
handover's `Claim:` is the governing claim on `--pair` (branch,
declaration) and is not self-retracted · ⛔ the same comment missing any
one field ⇒ still C9 JUDGED, the row names the refused release and the
missing key · ⛔ the ONE red kept: a cross-login `Claim:` with no
`Release:` at all · ⛔ a handover naming only one of two live claims
leaves the other standing · the remedy is SKILL.md's handover sentence
verbatim, with the 出处三件 source line and 让先到者 for a yield · each sentence
line is one SKILL.md bullet by shape (≤ 120 bytes, no bullet, no issue
id).

## 2. Before / after — every changed instruction line

`.claude/skills/pm-dispatch/SKILL.md`

| line (base → now) | before | after |
|---|---|---|
| :177 → :177 | `- dev 自己死了不等于维护者中止:子代理消失是正常死法,走死认领回收。` | `- dev
自己死了不等于维护者中止:子代理消失是正常死法,走接管(见认领节)。` |
| :472 → :472 | `- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段。` |
`- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段,接管同此。` |
| :474 | `- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领。`
| **unchanged, deliberately** — this line is the greppable source of
`RELEASE_ACT_RULE` in `check-half-states.mjs` (outside this claim's
surface); the handover reuses the act's two halves (② assignee + ①
`Release:` line, by the taker), stated in the new bullets |
| :492 → :492 | `- dev 侧早推分支,远程分支是在飞工作最硬的证据。` | `- dev 每个可编译小步即
push:容器随会话回收,未 push 的树救不回,可交接的只有远程分支。` |
| :493–:501 → :493–:497 | the nine liveness bullets (listed in §3) | `-
认领人不可达(token 耗尽/会话结束/身份退役)⇒ 接管:一条评论四件齐,⛔ 不判死活。` / `- ① 跨账号 `Release:`
点名被撤认领的 id 与 session ID,带出处三件(谁的指令/原话/在哪说)。` / `- ② assignee
同笔换人(`--unassign 旧 --assign 新`);③ 新 `Claim:`:新 session、续用分支与远程 sha。` /
`- ④ 交接记录:旧分支最后已 push 的 sha + 一句状态;读者只验①③形状,缺一件即非撤销。` / `- C9 只剩一种红:无任何
`Release:` 的跨账号 `Claim:`(真抢卡);线程上每条活认领都要点名。` |
| :502 → :498 | `- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。` | unchanged (a
mis-handed live seat still appends its correction) |

`.claude/skills/pm-dispatch/references/core-rules.md`

| line | before | after |
|---|---|---|
| :110 | `- 更早的他会话认领即让行并交出已诊断的一切;认领逾一天且无合并证据即疑死。` | `-
更早的他会话认领即让行并交出已诊断的一切;认领人不可达即接管,⛔ 不判死活。` |
| :111 | `- dev 自死不等于维护者中止,需显式信号;回收前先救工作树,有提交的活分支 ⛔ 永不回收。` | `- dev
自死不等于维护者中止,需显式信号;接管一条评论四件齐,只救已 push 的分支。` |

`.claude/agents/os-dev.md`

| line | before | after |
|---|---|---|
| :94 | ` - 有可展示内容即 commit、push 并开 draft PR,不等验证结束;验证结果到达即写进报告。` | ` -
每个可编译小步即 commit + push;有可展示内容即开 draft PR;接管只认远程分支最后 sha。` |

The dropped tail 「验证结果到达即写进报告」 survives at os-dev.md :95 (「未读到的判决写 NOT
MEASURED」) and :311 (「报告在本地验证走完时交付」).

## 3. SKILL.md deletion list — each retired line's surviving home

| retired line (base :493–:501) | surviving home |
|---|---|
| `死认领回收:认领 >~24h ⇒ 疑死;判死主腿 = 搜引用本卡的 PR、读其 merged/merged_at。` |
**retired outright** — the ruling replaces liveness judgement with the
human's word (:493 「⛔ 不判死活」) |
| `⛔ 判死不读 closes-list;承诺分支缺席与提交扫描失效只能支持判死、永不单独确立。` | retired outright
(no liveness judgement exists to bound) |
| `零引用 PR ⇒ 停下发问,⛔ 不判什么都没落地。` | retired outright; the "ask first" half
is the protocol itself — the handover IS the human's answer copied with
provenance (:494) |
| `回收前先救工作树:向任何派发 worktree 提交前先过存活/所有权检查。` | **retired outright** — the
hard fact at :492: a remote container's worktree is reclaimed with the
session; there is nothing to rescue |
| `或对树最新 mtime 过明确年龄阈值;⛔ 不凭 GitHub 侧静默动手。` | retired outright (same
reason); 「⛔ 不凭 GitHub 侧静默动手」 survives as the provenance requirement
(:494) |
| `过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。` | :492
(every compilable step is pushed by the dev — the WIP-rescue is moved to
the writer side, before the cut) + :496 ④ (the last pushed sha in the
handover record) |
| `WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。` | :496 ④
「一句状态」 — the taker records the branch's state and continues from the
remote sha; "diff before continuing" is the taker's ordinary care under
「读者只验①③形状」 |
| `WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。` | :496 ④ (sha + one status
sentence) — no WIP commit is written by anyone but the dev itself |
| `再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。` | :494 ① (the
`Release:` line, now with provenance instead of a silence window) + :497
(every live claim named) — 「有带提交活分支的认领永不回收」 is retired: a pushed branch
is precisely what the handover continues (:495 ③) |

## 4. PM mechanism assumptions — verified, one refuted

1. ✓ At `5e7d83c` = `32b5831` (no diff on the surface between them):
`CLAIM_RETRACTION_RULE` :1731 stated "⛔ never a DIFFERENT author's
line", `claimRetractions` skipped every candidate whose author differs
(:1778 `candidate.author === null || candidate.author !==
claim.author`), and `claimHandovers` judged cross-login claims after
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` (:2073, `2026-09-19T03:45Z`).
2. ✓ Reproduced before the change (2026-09-21T03:5xZ,
`PM_SWEEP_REPO=objectstack-ai/objectstack`): `--pair 19373` → exit 4, `✗
C9 — card objectstack-ai#17518 (delivering open PR objectstack-ai#19373) — 2 authors hold LIVE claim
comments … `os-bill`'s 5646971772 at 2026-09-12T15:54:12Z is the claim
that stood; `os-litant`'s 5749581295 at 2026-09-20T11:43:41Z took the
card from `os-bill` (dated AFTER the effective instant 2026-09-19T03:45Z
— JUDGED)`; `--pair 19335` → exit 4, `✗ C9 — card objectstack-ai#18670 (delivering
open PR objectstack-ai#19335) — 3 authors … `os-litant`'s 5717305863 … stood;
`os-steve`'s 5736537462 … (listed, informational); `os-bill`'s
5749165780 at 2026-09-20T10:14:08Z took the card from `os-steve` (…
JUDGED)`. After the change both STILL exit 4 (same rows, the remedy now
printing the four-item comment) — as predicted, until the seats post the
handover comments below.
3. ✓ SKILL.md :149 reads exactly
「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」 (ASCII punctuation); the
reader now reads exactly those three fields and quotes the line unbroken
(`HANDOVER_PROVENANCE_SOURCE`).
4. Tier S — `node scripts/pm/check-governed-merges.mjs --pr N` is run
once the PR number exists; the result is in the report. The PR stays
draft.
5. **REFUTED — ruling item ② spelling.** `label-write --clear-assignees
--assign NEW` is refused by the tool: `--clear-assignees cannot be
combined with --assign/--unassign` (`scripts/pm/label-write.mjs`
:417–:419). The one-write assignee swap is `node
scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue N
--unassign OLD_LOGIN --assign NEW_LOGIN` (`computeAssigneeTarget`:
target = current − unassign + assign, one write, read back). SKILL.md
:495 and the handover comments below use that spelling.

## 5. The handover comments the seats post (verbatim — ⛔ not posted by
this PR, ⛔ nothing written on objectstack-ai#17518 / objectstack-ai#18670 / PR objectstack-ai#19373 / PR objectstack-ai#19335
here)

Both were **simulated offline against the live threads** (the REST rows
of each card plus the drafted comment appended): C9 state `null`
(clear), pool = the handover comment, governing branch = the continued
branch, declaration `declared` / `yes`, C8 = 0; controls — the same
comment without 在哪说 ⇒ C9 judged `true`; the same comment with the
session id blanked on the `Release:` line ⇒ C9 judged `true`.
Placeholders in CAPITALS are the poster's to fill (its own session id /
login, the UTC stamp). The 原话 / 在哪说 values copy the maintainer's words
that adopted this protocol for exactly these two PRs (5754717208 § the
maintainer's turns; 5754797404 「同意」, which names PR objectstack-ai#19373 and PR objectstack-ai#19335
as the two the ruling unblocks); a fresher instruction naming the card
directly is a better value, if the seat has one.

### objectstack-ai#17518 (PR objectstack-ai#19373) — posted by the `domain:spec#1` seat
(`os-litant`, the taker already holding claim 5749581295)

```text
Release: handover of claim 5646971772 (`session_01MkQhmuuJAVDjmeWNixwDDH`, `os-bill`, branch `claude/issue-17518-assembled-body-json-schema`) and of this seat's own claim 5749581295 (`session_01LvwGppdonww4zGLWZo5rho`) · 因: the earlier claimant is a dev subagent session that ended on 2026-09-12 and cannot post its own `Release:`; the taker has delivered the whole diff on PR objectstack-ai#19373 · 去向: the `Claim:` below — same seat, same branch
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-project-manager` → `os-litant`, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 17518 --unassign os-project-manager --assign os-litant`
Claim: `domain:spec` seat 1 takes over objectstack-ai#17518 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `session_01LvwGppdonww4zGLWZo5rho`
Branch: `claude/issue-17518-assembled-package-body-inert-json` (continued at remote sha `aac764cc36113b4e52820c1695715f000ccbe1b4`, the head of PR objectstack-ai#19373)
Clause-②: yes
Handover: the released claim's branch `claude/issue-17518-assembled-body-json-schema` — last pushed sha `ed8dea17bd510100320ab42dbac6ec2a78e99deb` (read from origin at 2026-09-21); status: superseded — the whole diff was re-delivered on PR objectstack-ai#19373 at `aac764c` (checks green, `## Contract review` pending), nothing from the old branch is carried.
```

Why the seat's own 5749581295 is named too: the reader would otherwise
carry TWO live `Claim:` comments by `os-litant` (C8). Named on the same
`Release:` line it is retracted by the same-login arm, and the fresh
`Claim:` in this comment is the only one standing. If the posting
session differs from `session_01LvwGppdonww4zGLWZo5rho`, the `Session:`
line carries the new one.

### objectstack-ai#18670 (PR objectstack-ai#19335) — posted by the live `domain:spec` seat
(POSTER_LOGIN / POSTER_SESSION_ID; the taker of record,
`session_01JbZnqu8bt6YqfJsr9vaFb3`, was retired at 2026-09-20T23:34Z)

```text
Release: handover of claims 5717305863 (`session_01LvwGppdonww4zGLWZo5rho`, `os-litant`, branch `claude/issue-18670-refinement-projection-census`), 5736537462 (`session_01AmH9bKvGoLjiY86Q4Z3og2`, `os-steve`, branch `claude/issue-18670-banned-keys-projection`) and 5749165780 (`session_01JbZnqu8bt6YqfJsr9vaFb3`, `os-bill`, branch `claude/issue-18670-propertynames-not-pattern-arm`) · 因: the first two claims' work is merged (PR objectstack-ai#18729, PR objectstack-ai#19137; both branches absent on origin), and the third claim's session was retired at 2026-09-20T23:34Z with its PR objectstack-ai#19335 reviewed and green — none of the three can post its own `Release:` · 去向: the `Claim:` below
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-bill` → POSTER_LOGIN, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 18670 --unassign os-bill --assign POSTER_LOGIN` (a no-op when the poster IS `os-bill`; `pm:blocked` → `pm:dispatched` in that same write once the reader has landed)
Claim: `domain:spec` seat takes over objectstack-ai#18670 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `POSTER_SESSION_ID`
Branch: `claude/issue-18670-propertynames-not-pattern-arm` (continued at remote sha `1dfe2f40bce77270758d9b31b01dd8d46875a290`, the head of PR objectstack-ai#19335)
Clause-②: yes
Handover: branch `claude/issue-18670-propertynames-not-pattern-arm` — last pushed sha `1dfe2f40bce77270758d9b31b01dd8d46875a290` (read from origin at 2026-09-21); status: `## Contract review` PASS recorded at 5749728565 on this head, both carriers stripped, checks green — nothing left to build, the landing is the only step. The two older branches are absent on origin (their work merged as PR objectstack-ai#18729 / PR objectstack-ai#19137).
```

Why all three claims are named: C9 walks every LIVE claim; naming only
5749165780 would leave `os-litant` → `os-steve` → NEW as two hand-overs,
the last dated after the instant — still red. The row prints exactly the
ids to name (:497 「线程上每条活认领都要点名」).

## 6. Four-axis analysis

### 「No liveness test」 (ruling; 5754717208 §4's 「点名的是活认领 ⇒ 拒」 not kept)

- **实际业务需求** — measured: the three specimens (objectstack-ai#17518 / PR objectstack-ai#19373; objectstack-ai#18670
/ PR objectstack-ai#19335; objectui#9370) are all cases where the human already knew
the claimant was gone and the machine could not: a subagent session that
ended 2026-09-12, a seat session retired at 23:34Z, a retired identity.
In every one the holder's silence was total, so a liveness heuristic
(>24h, later comments, PR search, mtime) would have said "dead" only by
luck of thresholds, and a holder that posts one late comment would have
flipped a correct takeover into a refusal. The maintainer's words:
「这种情况通常都是人类口头交代的」 — the decision is already taken by a human; the
reader's job is to verify the copy, not to re-decide.
- **项目长远合理性** — a reader that verifies provenance is a pure function of
the thread (contract-first, no workaround); a liveness heuristic is a
second, contradictable oracle beside the human's word and needs its own
thresholds, exceptions and reconciliation windows (which is what
:493–:501 had become: nine lines of them). Long-term cost of the chosen
option: a bad handover is possible on a bad instruction — but it is
auditable (谁的指令 / 原话 / 在哪说 are on the card) and repairable (:498 「误伤活席位
⇒ 令其追加式更正」).
- **防 AI 写错** — the accept set is closed and mechanical: three named
keys, an id + session on ONE line, fail closed on any gap, and the
refusal names the missing piece. Nothing to guess; an AI seat that
half-writes the comment is told which field. A liveness test would be
the opposite — a tolerance rule ("probably dead") that hides a wrong
takeover behind a green.
- **创业阶段不扩散需求** — the ruling's own reason: 「我们系统开发了太多无用的门禁,反而在浪费时间」.
Nine heuristic lines retired, five protocol lines added, no staged
transition (the heuristics are gone at once — 「短期不考虑渐进」).
- Recommendation held: no liveness test, per the ruling.

### 「C9 keeps one red」 (a bare cross-login `Claim:` with no `Release:`
at all)

- **实际业务需求** — the red exists for the measured claim-jumps (objectstack-ai#17852's two
seats eight hours apart, objectstack-ai#15811's silent assignee move); those are
exactly the shape left red. The two finished PRs it blocked were
handovers, not jumps — they had no channel to say so; now they have one
comment.
- **项目长远合理性** — one state, one row, one repair (the handover comment) —
no widening of C8, no second selector; the effective instant stays as
history and still gates the narrowed red only.
- **防 AI 写错** — deleting C9 would let any later `Claim:` silently govern
(the pre-objectstack-ai#18862 SUPERSEDED exit-0 reading); keeping the red but printing
the four-item comment as the remedy makes the correct act the shortest
path. A refused attempt is listed with its reason instead of a bare "2
authors hold live claims".
- **创业阶段不扩散需求** — no new gate, no new label, no new tool: the remedy is
a comment in the spelling the protocol already has; the only added code
path is the provenance read.
- Alternative weighed and refused: retiring C9 entirely (「太多无用的门禁」) —
refused because the ruling itself keeps 「真正的抢卡」 red, and a jump is a
real, measured, silent failure.

## 7. Tests and gates (head `7a66ffe`; every exit captured before any
pipe)

- `node scripts/pm/check-clause2-carriers.mjs --self-test` → exit 0,
**1091 cases pass** (1075 at `origin/main`, run from a temp copy in the
same tree; the roster floor unchanged; both new case groups sit inside
their existing batteries).
- `--pair 19373` / `--pair 19335` → exit 4 before AND after (rows quoted
in §4.2); after the change each row ends with the four-item remedy
(`grep -c 认领人不可达` = 1 per log).
- Offline simulation of the two handover comments (§5): C9 clear,
governing claim = the handover, declaration `declared/yes`; controls
red.
- Derived union (`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, 48 commands, derived from the tree at
`693afd7` after the `origin/main` merge and re-run at `7a66ffe`): all
**48 of 48** commands exit 0 (run 2026-09-21T03:56Z–04:15Z, sequential,
each exit captured before any pipe; the list reconciled with `--ran`);
the slowest, `pnpm check:pm-dispatch-gates`, ran its full 1883-case
battery green at this head.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` /
`@objectstack/lint` not built — NOT a finding); after `pnpm exec turbo
run build --filter=@objectstack/formula --filter=@objectstack/lint`
under `os-verify-lock.sh` (VERDICT command-exit 0, 203 s) it answers
exit 0.
- `pnpm check:pm-dispatch-gates` (845 s on this box) red once on an
EARLIER draft: its governed-read census found a `readFileSync` of
SKILL.md in this reader's self-test (my "same words" pin). Removed — see
Deviations — and re-run green at the final head.

## Deviations (declared)

1. **The "ONE sentence" property is not a governed read.** A self-test
pin that reads SKILL.md makes `check:pm-clause2-carriers` a derived
family of SKILL.md and needs a `GOVERNED_READ_FLOOR` row in
`scripts/pm/dispatch-gates.mjs` (outside this claim's surface; a
gate-derivation change). Kept instead: `CLAIM_HANDOVER_SENTENCE_LINES`
(the remedy prints the five lines verbatim) + the twin rule at review +
a shape pin (each line ≤ 120 bytes, no bullet, no issue id). Open
question for the seat: register the read so a SKILL.md edit that breaks
the sentence reds the reader (recommended; a two-line floor row).
2. **SKILL.md ceiling not lowered** (813 → could be 809):
`check-skill-line-ratchet.mjs` is outside the surface; headroom 4 is
reported, the seat lowers it if wanted.
3. **:474 left byte-identical** (see §2) — the alignment the dispatch
asked for is carried by the new bullets rather than by editing the line
that a sibling file quotes.
4. **Ruling ② spelling corrected** (`--unassign OLD --assign NEW`), see
§4.5.

## Acceptance notes (off-path; noted, not filed — ⛔ no card filed by
this dev)

- `scripts/pm/check-half-states.mjs` H47 leg (b) sentence still quotes
「释放回队(`Release:` 行载因)」 as "the dead-claim route" — that SKILL.md line is
retired here, so the quotation is stale prose in a remedy sentence (a
doc nit, not a defect; carrier: the `domain:skills` seat on its next
half-states touch).
- `references/platform-readings.md` :391 「处置 = 死认领回收加 worktree 抢救,⛔
不重核前提、不升级」 names the retired route (a host-signal disposition line;
outside this claim's surface — the seat's twin-rule follow-up, one line:
「处置 = 接管(认领节),⛔ 不重核前提、不升级」).
- `check-clause2-carriers.mjs`'s C9 docblock still carries the objectstack-ai#18862
ruling history verbatim (「the holder posts `Release:`; the taker posts
nothing until then」 as the ruling's quoted words) — kept as history, the
new paragraph below it states the change; no action.
- `.claude/skills/pm-dispatch/SKILL.md` :272 「维护者强制接管令 … ⛔
不取在飞卡,由原认领者跟完」 is the seat-level forced takeover (a blanket order) and
is not contradicted by a per-card handover on a named instruction; left
as is.

## 维护者速读(草稿)

**改了什么**:把「死认领回收」换成「接管协议」。一个 agent 做到一半没 token 了,新会话在**一条评论**里接管:① 跨账号
`Release:` 点名旧认领的评论 id 与 session ID,并带出处三件(谁的指令 / 原话 / 在哪说);② assignee
同笔换人;③ 新 `Claim:`(续用远程分支与 sha);④
一句交接状态。认领读者(`check-clause2-carriers.mjs`)按形状接受①③,不再判死活;C9 只剩「没有任何
`Release:` 的跨账号抢卡」一种红。SKILL.md 删掉九行判死启发式,换成五行接管规则;os-dev.md
把「早推分支」提为硬要求(每个可编译小步即 push)。

**为什么改**:两张已复核完毕的成品 PR(objectstack-ai#19373、objectstack-ai#19335)今天落不了地,只因为旧认领人已经不在、没人能替它写
`Release:`;而「代执行他人指令要带出处三件」这条规矩早就在 SKILL.md
里,只是读者不读。您的原话:「这种情况通常都是人类口头交代的……我们系统开发了太多无用的门禁」。


**风险与代价(含回滚)**:风险是一条编造出处的接管评论会被读者接受——但出处三件留在卡上可审,误伤活席位按既有规则追加更正。代价是读者多一条判形状的分支(+120
行,含自测)。回滚 = revert 本 PR,一次 revert 即回到判死启发式与旧 C9。

**席位意见**:(席位填写)

**你要做的**:本 PR 是受管面(`.claude/**`),由席位达档复核后落地,不需要您动手;落地后 spec 席按正文第 5
节的两条评论接管 objectstack-ai#17518 与 objectstack-ai#18670,两张 PR 即可入队。若您希望读者对「SKILL.md
与读者同句」做机械钉死(而非复核时人工核对),点一下头,席位在 `dispatch-gates.mjs` 登记一条 governed read
即可。

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

---------

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