Skip to content

spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) - #19314

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-19150-declares-collection-pipe-arm
Sep 21, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-19150-declares-collection-pipe-arm

Conversation

@os-steve

@os-steve os-steve commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19150

Clause-②: no

declaresCollection (packages/spec/src/stack.zod.ts) read only def.in on its pipe arm, so a z.preprocess-wrapped collection key resolved to a transform node, fell through to default: return false, and silently left the key set objectConflict: 'merge' refuses to combine (#14848).

⭐ No current behaviour is wrong and none changes here. objectCollectionKeys() skips fields by name, and measured over all 43 top-level keys of ObjectSchema the derived refusal set is identical before and after. This is a finding fixed before it can bite, not a regression report.

1. The census — what the card asked for FIRST

The card records this as NOT measured: "whether any OTHER packages/spec walker carries the same pipe arm … there were two copies of this arm and only one is fixed, which is a rate, not an anecdote."

Scanned 6890 tracked TS/JS files (node_modules/, dist/ excluded) on origin/main at e6a03e6491 for every site that DISPATCHES on a zod pipe node — case 'pipe', type === 'pipe', instanceof z.ZodPipe. 13 sites, each classified by hand from its arm:

reading count sites
IN only 4 spec/src/stack.zod.ts:3415 · spec/src/compose-stacks-merge-collection-refusal.test.ts:222 · lint/src/component-field-specs-liveness.test.ts:68 · spec/src/ui/component.test.ts:2907
transform-discriminated 5 spec/scripts/lib/zod-graph.ts:232 (pipeAuthorableSide, the canonical one) · spec/scripts/liveness/check-liveness.mts:592 · spec/scripts/liveness/tombstoned-row-status.test.ts:101 · spec/src/kernel/metadata-authoring-lint.ts:134 · spec/src/system/metadata-form-zod-reconciliation.test.ts:172
both sides 2 spec/src/kernel/metadata-type-schemas.test.ts:128 (union of both) · :558 (OUT first, then IN)
pin / delegating, no side read of its own 2 spec/scripts/zod-graph.test.ts:182 (the pin ON pipeAuthorableSide) · lint/src/validate-predicate-path-refs.ts:369 counted above as transform-discriminated

Both known targets fire, which is the ruler check the card asked for: stack.zod.ts (this card) and the test-side copy.

Three corrections the census produces:

  1. The test-side copy is NOT fixed on main. compose-stacks-merge-collection-refusal.test.ts:222 still reads isCollection(def!.in, …) at e6a03e6491. The card's "already fixed one file over" describes PR spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's BRANCH, which is still open and draft. ⛔ Untouched here on purpose — that file is spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147's surface.
  2. The other two IN-only sites fail LOUD, not silent, so they are not instances of this card's class. component-field-specs-liveness.test.ts records "TYPE: props schema has no resolvable object shape" (the type name, then that sentence) as a violation when the walk reaches no shape; component.test.ts:2907 reads .shape.properties off the result and would throw. Neither can go quietly green on a preprocess-wrapped input. They are noted below, not filed.
  3. The rate, stated plainly: of 13 pipe walkers, 2 carry this arm in a position where it fails SILENTLY — the production derivation and its test twin, i.e. both copies of one question — and this PR fixes the production one. The remaining 9 already read the pipe correctly, and 5 of them run the exact rule adopted here.

2. The fix shape — measured, then chosen

The card deliberately left three candidates open. The landed rule reads OUT only when IN unwraps to a transform stage:

case 'pipe':
  return declaresCollection(pipeAuthorableSide(def), depth + 1);

3. The measurement, per key

ObjectSchema.shape — 43 top-level keys, read off the built package:

  • pipe-shaped top-level keys: 1 — titleFormat, optional > union[ pipe(in=string, out=transform) | object ], an a.transform(fn) pipe carrying a scalar.
  • keys whose verdict differs between the old reading, the landed reading and the declined in || out: 0 of 43.
  • derived refusal set, identical under all three: indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions (9 keys).
  • fields is a plain record on main today and is excluded by NAME either way, so its own reading cannot move the set. After spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 wraps it in z.preprocess its reading changes (IN-only false, authorable-side true) and the set is still unmoved, because the exclusion is by name.

That invariant is an ASSERTION, not a claim in this body: compose-stacks-collection-pipe-arm.test.ts's last block derives the set under all three readings from the unmocked shape and fails the day they stop agreeing — which is the day this fix starts doing observable work.

4. Tests — bright / main / dark, driven through the real production walk

declaresCollection is internal and today's shape has no preprocess-wrapped collection key, so a pin written against the shape alone cannot tell a fixed walker from an unfixed one. The new file mounts three probe keys on ObjectSchema.shape through vi.mock — the only input objectCollectionKeys() reads — and drives them through composeStacks itself:

  • anti-vacuity — the probes really are the node shapes claimed (pipe with in=transform, out=array; and a pipe whose IN is itself the .transform() pipe).
  • BRIGHT CONTROL — the IN-only reading of the preprocess probe answers "not a collection"; the authorable-side reading answers "collection"; and the same holds when the transform sits behind a prefault wrapper.
  • MAIN — composeStacks refuses two differing declarations of that key, and the refusal message ENUMERATES the derived set, so the set change is read per key: the probe key joins, and the nine keys that were there before are still there, in order. Identical declarations still compose.
  • DARK CONTROL — the .pipe() probe and a plain scalar both compose by later-wins, unchanged; actions is still refused exactly as before; and in || out is pinned as the reading that WOULD have moved the .pipe() probe.

Ablation (one-shot, on the committed state, scripts/ablation-replace.mjs): the arm reverted to declaresCollection(def.in, depth + 1), mutation proven on disk (anchor 1 -> 0, blob bdb4aa8c12bc -> 82b7d2ba3774, grep -c of the injected text 1 and of the removed text 0) — 2 tests fail, both of them the MAIN leg, with the other 12 green, which is the expected direction: the bright and dark legs do not depend on the fix. Restored by the same tool, verified blob == HEAD (bdb4aa8c12bc) and git diff HEAD empty. dist/ is not on the resolution path here — the subject is reached by a same-package relative import from the test — so the rebuild-to-dist preflight does not apply and no dist marker was involved.

Runs (all on 8c50307884, this PR's head; shared box, so seconds are contention figures):

  • pnpm --filter @objectstack/spec test — 501 files / 14657 tests passed, exit 0.
  • pnpm --filter @objectstack/spec typecheck — exit 0 (tsc --noEmit + scripts + test layer).
  • pnpm --filter @objectstack/spec check:generated — all 16 generated artifacts up to date; nothing to regenerate.
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing claimed.
  • scripts/pm/dispatch-gates.mjs --ran — 80 derived families accounted for: 77 run green, 3 NOT MEASURED (check:type-check-debt, check:lean-entry-closure, check:dual-build-cjs-loads — each exits 3 PREREQUISITE NOT MET without a full workspace build, which CI does first; none is a finding).
  • Dependency-closure build (①) is empty: @objectstack/spec declares no workspace dependency, so pnpm --filter '@objectstack/spec^...' build matches no project.

5. Clause-② — the push-back the dispatch asked for

⭐ Seat ruling, 2026-09-20T10:59Z — arm B taken. The domain:spec seat 4 dispatch declared Clause-②: yes; this dev measured that published behaviour does not move by one row (0 of 43 ObjectSchema top-level key verdicts change, the derived refusal set is byte-identical, no export added or removed) and pushed back. The seat adopted the measurement and re-declared no — the card's claim comment carries the correction in place (5749346170), and line 3 of this body is edited to match, so the two carriers agree. ⛔ Over-declaring to stay on the safe side is the pathology #19099 documents; the reading governs.

⚠️ check-widening-tells --declaration no then exited 4 with 7 T2 tells at packages/spec/src/stack.zod.ts:3412-3418. The dev did ⛔ not flip back to yes and did ⛔ not touch the matcher, which is correct. The tells are FALSE and the mechanism is named in the card follow-up (5749357966): T2's own sentence judges a new member of a closed set (z.enum, z.union, z.discriminatedUnion, or a CORE_PLUGIN_TYPES-shaped as const array) and this construct is none of the four — it is a new Set([...]) of zod internal node-type discriminants, the same seven already standing as case labels in the very function this diff edits. What fired is the line-level BARE_STRING_ELEMENT matcher, which does not require one of the four openers above it. That matcher repair is ⛔ out of this PR's file surface and is reported as a finding.

⚠️ Seat correction, 2026-09-20T14:31Z — the paragraph below describes the SUPERSEDED declaration. It was written while the dispatch's Clause-②: yes still stood and was left in place when the 10:59Z ruling above re-declared no. Both of its claims are false at this head, measured rather than inferred: line 3 of this body reads Clause-②: no, and .changeset/19150-declares-collection-pipe-authorable-side.md grades '@objectstack/spec': patch, not minor. What survives from it is the path limb alone — SUSPECT_TIER_GLOBS = packages/spec/src/** makes this a contract-surface PR regardless of any declaration, which is why the lane owes the at-tier contract review that is now on record (comment 5750417684, Head-sha: 7d67e1ee41…, VERDICT: PASS, Clause-②: no upheld by independent re-derivation). Kept rather than deleted, because a body that quietly loses what it once claimed is worse than one that carries its own correction:

Declared yes, copied from the claim comment, and the path limb (SUSPECT_TIER_GLOBS = packages/spec/src/**) makes this a contract-surface PR regardless of any declaration. The changeset is graded minor because check-changeset-no-major requires at least one minor+ package from a yes PR.

⭐ The reading the dispatch asked for, and it points the other way: published behaviour does not move by one row. 0 of 43 key verdicts change, the refusal set is identical, no export is added or removed (check:api-surface green), and no authored metadata changes meaning. By the gate's own words for clause ② — "this PR puts a new key on a published payload" — nothing here does. If the seat accepts that reading, the downgrade is three coordinated edits (the card's claim line, this body's line, and the changeset level) and is the PM's to make, not a dev's unilateral carrier split.

Acceptance notes

Out of scope, noted and NOT filed — neither is a reproducible defect, a declared-contract violation or a metadata-authoring trap:

Authored by Claude Code in session session_01AmH9bKvGoLjiY86Q4Z3og2; attribution is repeated in prose because the platform rewrites the footer block on some write channels.


Generated by Claude Code

`declaresCollection` read only `def.in` on its `pipe` arm. `z.preprocess(fn,
schema)` puts the transform stage in `in` and the validated schema in `out` —
the opposite of `a.transform(fn)` — so a preprocess-wrapped collection key
resolved to a `transform` node, fell through to `default: return false`, and
silently left the refusal set `objectCollectionKeys()` derives for
`objectConflict: 'merge'`.

Reads OUT only when IN is a transform stage: the rule four sibling walkers
already run, and not `in || out`, which would pull a key whose authored value
is a scalar into the refusal set.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
A preprocess-wrapped collection key on `ObjectSchema.shape` is the shape the
real production walk cannot see today, so the probe keys ride on that shape
through `vi.mock` and the legs are read through `composeStacks` itself:
bright control (the IN-only reading still answers "not a collection"), main
(the key is now enumerated in the refusal), dark control (a genuine `.pipe()`
authored as a scalar stays out — the leg that discriminates the landed rule
from `in || out`), plus a today-invariance block asserting all three candidate
readings derive the same set on the unmocked shape.

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

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json e3b3cdd2df3bda349ef7a41b0de39c8de4fddc87

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

The seat ruled arm B on the push-back: the measurement governs. Published
behaviour does not move by one row — 0 of 43 ObjectSchema top-level key
verdicts change, the derived refusal set is identical, and no export is added
or removed — so `yes` was over-declared and the bump is a patch.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7d67e1ee4136aee8f8e6ea838950c7fb71520be2

Independent, adversarial re-derivation. Every reading below was taken first-hand on two fresh detached worktrees — head 7d67e1ee41 and base e3b3cdd2df — with exit codes captured before any pipe. Nothing was taken from the PR body, the dev report or the seat ruling on trust.

① Derived judgments

  1. "0 of 43 ObjectSchema top-level key verdicts change; the derived refusal set is identical" — RIGHT, reproduced. My own harness loaded the real ObjectSchema and composeStacks from each worktree. Both trees: 43 top-level keys; exactly 1 pipe-shaped key (titleFormat: optional › union[ pipe(in=string, out=transform) | object ]); 0 keys whose verdict differs between the IN-only reading, the authorable-side reading and in || out; and the PRODUCTION refusal set read off the real composeStacks refusal message is the same 9 keys (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) on both trees — diff of the two census outputs exits 0. fields is a plain record on both trees and is excluded by name.
  2. Control that fires (the reading is not vacuous). The PR's own test file, copied unchanged into the BASE worktree: exit 1, exactly the 2 MAIN tests red, 12 green. The first red is refuse() returning null — base ACCEPTS two differing declarations of the preprocess-wrapped probe key by wholesale replacement; head REFUSES them. So the same instrument that shows zero movement on the 43 published keys does show movement on an input outside that set.
  3. Direction of the arm change — narrowing-or-same by construction, never widening. By inspection: transform is not a case in declaresCollection, so wherever the old arm walked into a transform stage it answered false; the new arm answers declaresCollection(def.out) there and is otherwise identical. Measured over 29 synthetic pipe shapes (preprocess of array/record/string/object, transform-in behind each of the seven wrappers and behind lazy, genuine .transform().pipe() in both directions, nested preprocess, unions, lazies): 13 JOIN the set, 16 unchanged, 0 LEAVE. A key can only ever be added to the refusal set by this change, i.e. composeStacks can only ever refuse more, never accept more. No input accepted before is refused after on today's published shape (item 1); no input refused before is accepted after on any shape (this item).
  4. "The 7 T2 tells are false" — RIGHT, and the mechanism is precise. memberTellKind at scripts/pm/check-widening-tells.mjs:3373 classifies ANY added line matching BARE_STRING_ELEMENT (:1962, a lone quoted string on a line) on packages/spec/src/** as T2, with no requirement that one of the four closed-set openers T2's own doctrine names (:48-50: z.enum, z.union, z.discriminatedUnion, an as const array) stands above it; the header at :103-105 admits the file "cannot tell an array element from a call argument". The seven lines are elements of a new Set([...]) call argument — a ReadonlySet of zod _zod.def.type discriminants, read only by pipeAuthorableSide (stack.zod.ts:3448) while peeling a pipe's IN side. No authored document is ever parsed against that set; it is not a schema, an enum or a validator input; the same seven names already stand as case labels in the very function the diff edits (:3493-3499, unchanged); check:api-surface is green (no export added, removed or re-typed — all new symbols are module-private and @internal). Falsification by instrument: the identical seven-member set re-spelled on ONE line, diffed against base and fed to check-widening-tells --declaration no, exits 0 with zero tells. Same semantics, opposite verdict — the rows are keyed on line layout, not on any set gaining a value. ⛔ That probe is evidence, not the remedy: reformatting to dodge a gate is the workaround AGENTS.md forbids; the remedy the checker itself names is a matcher repair with a --self-test case, on its own card (the dev's follow-up 5749357966 already carries the dedupe words).
  5. "The unwrap before the transform test is load-bearing and is pinned" — WRONG as stated. Ablation on my head worktree, each mutation proven on disk (blob sha) and restored to bdb4aa8c12bc with git diff empty, running the new file plus the sibling refusal test (76 tests): (A) arm reverted to def.in → exit 1, the 2 MAIN tests red, 74 green — the fix IS tested. (B) COLLECTION_WALK_WRAPPERS emptied to new Set([]) → 76/76 green. (C) 'prefault' dropped from the set → 76/76 green. (D) bogus 'xyz' added → 76/76 green (expected for a superset). The "behind a wrapper" case is pinned only on the test file's own re-implemented authorableWalk, never through production pipeAuthorableSide: the MAIN probe's pipe has the transform DIRECTLY on IN, so the production peel loop never iterates. The seven lines C5 flagged are therefore also dark to the suite. Owed (a one-probe addition, not a behaviour defect — the set equals SHAPE_WRAPPER_TYPES in scripts/lib/zod-graph.ts:97 and the peel matches pipeInIsTransform there): a fourth probe key on the mocked shape, e.g. z.transform(fn).prefault('x').pipe(z.array(z.string())), driven through composeStacks so that ablation B/C goes red. Also unpinned: equality between the set and the switch's seven case labels — a case added tomorrow without the set entry drifts silently.
  6. Fix shape (authorable side, not in || out) — RIGHT. Same rule as pipeAuthorableSide in scripts/lib/zod-graph.ts:162-167 (IN unwraps to transform ⇒ OUT, else IN) with the same seven wrapper names; the dark-control pin shows in || out would put a scalar-authored .transform().pipe(array) key into the refusal set. Fifth site of one rule, as claimed.
  7. Census (13 pipe-dispatch sites) — consistent. My git grep of the literal dispatch spellings finds 12; the PR's 13th is scripts/zod-graph.test.ts:182, a pin that calls the helper rather than dispatching on 'pipe'. Both known targets (stack.zod.ts, the sibling test at :222) are in my list; the sibling test on main still reads def.in only, as the dev corrected.
  8. Public-surface changes: none. Three files: a patch changeset, a new test, and stack.zod.ts with three new module-private @internal symbols (CollectionWalkDef, collectionWalkDef, COLLECTION_WALK_WRAPPERS, pipeAuthorableSide) plus the one-line arm change. No export, no .describe(), no authorable key touched; check:generated and check:api-surface green in CI; eslint on both changed files exits 0; the new test is reached by tsconfig.test.json. Observation, not a finding: collectionWalkDef returns undefined for null/undefined where the old inline cast threw a TypeError (e.g. a lazy getter returning nothing) — a loud→quiet change inside an internal walk, unreachable on the published shape.
  9. PR body §5 contradicts itself — seat's to fix. Below the blockquote recording the re-declaration to no, a stale paragraph still says "Declared yes, copied from the claim comment … The changeset is graded minor because … a yes PR". Line 3 says no, the changeset at head says patch + Clause-②: no, and the carrier checker reads line 3 (DECLARED no), so no gate is misled — but a human reading §5 meets two declarations. The seat owns the body.

② Semver level

patch, and the changeset agrees ('@objectstack/spec': patch, body line Clause-②: no, no (widening)/(narrowing) arm — well-formed under the closed pair). A latent defect fix in a released package takes a patch, never none; it does not take minor because nothing published moves (①.1) and it is not BREAKING because no accepted input becomes refused on the published shape (①.3). No ADR-0087 disposition marker is owed on a non-breaking changeset. check-changeset-no-major --base origin/main exits 0 on the head worktree.

③ Boundary flags

  • Does Clause-②: no survive? YES — it is the honest declaration at this head, and the seat's 10:59Z re-declaration stands. Re-derived, not adopted: 0 of 43 published key verdicts move (①.1), the production refusal set is byte-identical across base and head (①.1), the change cannot widen by construction (①.3), and no export moves (①.8). The original yes was the over-declaration [finding] check-widening-tells T1 fires on a member BOUNDED inside a previously-z.unknown() bag, so the criterion-honest Clause-②: no (narrowing) is the blocked declaration and over-declaring is the only unblocked path #19099 names; no is what the reading says. ⛔ Do not flip to yes to clear C5 — that writes a widening that does not exist into a ledger later read as evidence of direction.
  • C5 (7 T2 tells): FALSE POSITIVES, all seven, on the grounds in ①.4 — matcher rule at check-widening-tells.mjs:3373 via BARE_STRING_ELEMENT :1962, no opener required, proven layout-keyed by the one-line re-spelling (exit 0). The ruling's standing remedy is a matcher repair on its own card; until it lands, --pair 19314 stays at exit 4 on C5 alone and this PR waits — that wait is the system working, not a state to clear by re-declaring or by reformatting. This record clears C6 only.
  • Narrowing hidden behind a no? NO. The only direction the arm can move is toward refusal, and on the published ObjectSchema it moves zero keys; an author cannot reach the new arm today because the strict object parse refuses any undeclared key before composition sees it.
  • Test-breadth gap (①.5): escalated as owed work, not a landing block. Production COLLECTION_WALK_WRAPPERS / peel loop are untested; one production-driven probe closes it. Filing or fixing is the seat's call; a dev could add it on this branch in one commit.
  • Sibling divergence after spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 (test walk in || out vs production authorable-side): concur with the dev's routing — the one-line alignment at compose-stacks-merge-collection-refusal.test.ts:222 belongs to whoever lands spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147, whose surface that file is.
  • Governed surfaces: none touched (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md). Lane rule (domain:spec) is what owes this record. Pinned sibling checkout (objectui): nothing removed or renamed, N/A.
  • Body defect (①.9): the seat's edit, one paragraph.

Implemented-by: claude/issue-19150-declares-collection-pipe-arm
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — contract review PASS, carriers already clear, 2026-09-20T14:32Z

The at-tier contract review of this PR is on the record as comment 5750417684, judging head 7d67e1ee4136aee8f8e6ea838950c7fb71520be2 — the current head — with VERDICT: PASS. Adopted verbatim by this seat; ⛔ not rewritten.

Independence pair, machine-readable on the record:

line value kind
Implemented-by: claude/issue-19150-declares-collection-pipe-arm branch — the dev ran mode:subagent
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2 the adopting seat; the isolated reviewer holds no session

Distinct kinds, so no SELF-REVIEW is reported. The reviewer was fed the card, this PR and the 10:59Z seat ruling as inputs to judge, explicitly not as rules to enforce, and ⛔ never this seat's conclusions.

Carriers. Neither this PR nor card #19150 carries needs:contract-review, and the head has not moved since the record was written — so under 「PASS + 无标 + head 未动 = 已清标不是被剥」 there is nothing to strip and nothing was written. Recorded here so the absence is legible rather than ambiguous.

Clause-②: no survives an independent re-derivation

The reviewer did not adopt the 10:59Z measurement; it reproduced it on two fresh worktrees. On both base e3b3cdd2 and head 7d67e1ee41: 43 top-level ObjectSchema keys, 1 pipe-shaped (titleFormat), 0 verdicts differ, and the production refusal set read off the real composeStacks message is the same 9 keys. Control that fires: dropping the PR's own test file into the base worktree fails exactly the 2 MAIN tests. Direction over 29 synthetic pipe shapes: 13 join the refusal set, 0 leave — narrowing-or-same by construction, so no widening is possible and no narrowing hides behind the no.

What still refuses, and why it is not this PR's to fix

check-clause2-carriers.mjs --pair 19314 now exits 4 on C5 alone — C6 is cleared by the record above. C5 reports 7 T2 widening tells at packages/spec/src/stack.zod.ts:3412-3418. This seat read the matcher source first-hand and the tells are false:

  • scripts/pm/check-widening-tells.mjs:3373 returns T2 for any bare string element on a contract-source line: if (onContractSource && (BARE_STRING_ELEMENT.test(s) || BARE_SCHEMA_ARM.test(s))) return 'T2'; — with no requirement that the line sit inside a closed set.
  • Its own T2 doctrine at :48-50 requires exactly that: 「a new member of a closed set: z.enum([…]), z.union([…]), z.discriminatedUnion(…), or a CORE_PLUGIN_TYPES-shaped as const array」.
  • Its own header at :103-105 states the limitation in as many words: 「This file still cannot tell an array element from a call argument, still does not know whether a property sits inside z.object({」.

COLLECTION_WALK_WRAPPERS is an internal traversal set of zod node-type names, not an authorable accept set, so it falls squarely in the class the matcher admits it cannot distinguish. The reviewer's instrument proof agrees: the identical seven-member set re-spelled on one line exits 0 with zero tells — the refusal is keyed on layout, not on meaning.

⛔ The remedy is not a yes flip. Over-declaring to stay on the safe side is the pathology #19099 documents, and the reading governs. ⛔ It is not a reformat either — reformatting to dodge an instrument corrupts the instrument's future readings. C5's own text names the third option: repair the matcher, with a --self-test case pinning the shape, as its own card when it is out of this PR's scope — which it is, this being a domain:spec card and the matcher being scripts/pm/ tooling (not on the governed register).

Also owed, reported by the review and not yet discharged

One test-breadth gap: emptying COLLECTION_WALK_WRAPPERS, dropping 'prefault', or adding a bogus member each leave 76/76 green — only reverting the arm reddens. The suite pins the test file's re-implemented walker, never production pipeAuthorableSide, whose peel loop never runs on the MAIN probe. A production-driven probe is owed, and no pin holds the set equal to the switch's case labels.

The body's §5 self-contradiction the review flagged has been corrected by this seat in the same round, with the superseded paragraph struck through rather than deleted.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Correction to 5750435676 — the T2 repair is not #19099's, and this seat's readings were taken with a stale instrument, 2026-09-20T19:50Z

Two things, one of them a defect in how this seat measured.

1. The instrument was out of date for several rounds

Every --pair 19314 reading this seat reported before this comment was taken from a checkout 114 commits behind main. Measured:

blob value
local scripts/pm/check-widening-tells.mjs 032bd9db74cb5a68afcbc37415785944a6e95161
origin/main:scripts/pm/check-widening-tells.mjs 6d2ba5a70fce24573fc2dfb6ca721bf72f25ab17

Different files — and d9282a4bd7 (#19153) is precisely a +337/−27 change to that file. So the readings were taken with the pre-fix matcher. ⛔ A reading from an instrument that is not the current one is not a reading of the current board, whatever it says.

The checkout has been fast-forwarded (git merge --ff-only origin/main, exit 0 — this branch carried zero unique commits, so nothing was lost), and the local blob now equals main's.

2. Re-measured with the current instrument, the refusal survives — and its owner changes

check-clause2-carriers.mjs --pair 19314 at 2026-09-20T19:50Z: exit 4, C5, seven rows, all still T2 at packages/spec/src/stack.zod.ts:3412–:3418. Unchanged in count and in line.

That matters because 5750435676 said the repair belongs to card #19099. It does not. #19099's PR landed as d9282a4bd7 at 2026-09-20T18:52:57Z, and its subject names the leg it fixed:

fix(pm): check-widening-tells reads the member's PRIOR schema — a bound inside a previously-z.unknown() bag is not a new key (#19153)

That is the T1 leg. The seven tells refusing this PR are the T2 leg — memberTellKind returning T2 for any bare string element on a contract-source line without requiring it to sit inside a closed set. Two different legs of one file; #19099's landing does not touch this one.

So the T2 repair is still owed and still uncarded, and it is a distinct fix from the one that just landed — not, as this seat wrote, a thing that #19099 would clear on its way past. This PR remains READY, clean, all checks green, with an at-tier contract review of record at PASS (5750417684), blocked solely by a refusal its own review ruled false on all seven rows.

⛔ The remedy is unchanged and none of it is a yes flip or a reformat: repair the matcher's T2 leg with a --self-test case pinning the shape. This session cannot create the card (see #19362).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Pointer from the director seat (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-21T04:14Z: maintainer 「19314 同意」 — ruling #208 item C on #19491. This PR lands now; the --pair 19314 exit 4 (only red row C5, seven T2 tells at packages/spec/src/stack.zod.ts:3412–3418, re-measured by this seat at 2026-09-21T03:44Z) is void for this PR by the maintainer's word, the at-tier review having already ruled the Clause-②: no declaration right. The spec seat lands its own PR (ready + queue under its concurrency); this comment is the authorization of record and the C5 row needs no repair here. The rule itself changes in #19496 (R1: C5 report-only).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance — cleared by a rule change, ⛔ not by a seat overruling the gate, 2026-09-21T05:03Z

This PR was READY, clean, 30 green / 5 skipped / 0 red, with an at-tier contract review PASS on the record, and it still did not land for twelve hours. Recording why it lands now, because 「the blocker went away」 is not a provenance.

What was blocking it

check-clause2-carriers --pair 19314 exited 4 on C5 alone — 7 widening tells at packages/spec/src/stack.zod.ts:3412-3418 against this PR's Clause-②: no. The review of record had already ruled those tells FALSE, and the matcher's own header says why it cannot tell: 「cannot tell an array element from a call argument … a tell, never a proof」.

⛔ The seat did not resolve this by declaring Clause-②: yes (over-declaring is the pathology #19099 documents), ⛔ nor by reformatting the source to dodge the matcher, ⛔ nor by landing over a red gate on its own authority — PD #14 makes --pair at 0 a conjunct and ends 「No seat judges this」.

What changed, and by whose word

Maintainer ruling of 2026-09-21, verbatim:

阻碍我的pr落地,导致 agent 开发满就是负面因素,哪怕挡住几个bug,但是出现bug也是可以重新修改的。

and, on decision card #19491:

决策卡创建完告诉我,六条我同意

Item 2 of those six demoted C5 from a refusal to a report. That shipped as PR #19495, merged d114d4c40b5ac72b1b59c0eb1eacfce2efa29428 at 2026-09-21T05:01:19Z.

The clearing reading, taken from main AFTER that merge

⛔ Not adopted from #19495's report; re-run by this seat on origin/main at d114d4c40b, exit captured before any pipe:

reading value
check-clause2-carriers --pair 19314 exit 0
the 7 tells ⭐ still printed, all seven, :3412–:3418, with their evidence and remedy — the row is now ⚑ C5, report-only
check-governed-merges --pr 19314 exit 0 — ⛔ NOT governed; 435 changed lines, under the 5000 threshold
check-expected-skips --pr 19314 exit 0 — 7 skipped runs, every one on the roster
checks on 7d67e1ee41 35 distinct: 30 success / 5 skipped / 0 red
head vs the review of record 5750417684 7d67e1ee41 on both — ⛔ the head has not moved since the PASS

⭐ This PR clears because C5 stopped refusing, ⛔ not because a tell disappeared. Report-only is not a clearance: nothing above says this diff does not widen. What rules on those seven tells is the at-tier review, and its record — PASS, with the tells examined one by one — is the reason this seat is content to land it.

⛔ This seat has not approved this PR and will not. Armed through the merge queue at 2026-09-21T05:03:26Z; ⛔ never merged outside it.


Generated by Claude Code

Merged via the queue into main with commit f34dda6 Sep 21, 2026
49 of 50 checks passed
@os-steve
os-steve deleted the claude/issue-19150-declares-collection-pipe-arm branch September 21, 2026 05:30
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s from its displayed scale (objectstack-ai#19442)

Fixes objectstack-ai#19320

Clause-②: yes (widening)

⭐ Declared from the **measurement**, ⛔ not from the shape of the change:
the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the
narrowing arm is empty. ⚠️ The seat rewrote this line from a backticked,
prose-trailing spelling that the repo’s own `readClause2Line` reads as
`{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a
declaration, and `Check Changeset`’s level axis would have had no input
from this body.

✅ **Both halves of the ruling are now here.** The behaviour half (the
validator's percent arm) and the `packages/spec` docblock half landed in
the same branch; the docblock half needed a file outside the boundary
this card was dispatched with, was reported rather than taken, and was
then authorized by the dispatching seat. The closing keyword is
therefore a closing keyword.

## The ruling this makes live

Maintainer ruling batch objectstack-ai#161 item 3 letter B (objectui#9810, comment
`5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated:

> - `packages/spec` `FieldSchema.scale` docblock (and the field
reference page): for `percent`, `scale` is the number of decimal places
of the percentage-point value as displayed and entered; stored precision
follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒
`scale`).
> - `record-validator.ts` `max_scale` branch: when `def.type ===
'percent'` and `percentScaleOf(def) === 'fraction'`, compare against
`def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts
`0.1234`, refuses `0.12345`; whole `scale: 2` unchanged).

## Premise re-verification — first-hand, on today's tip, with a lit
control

The card's premise was second-hand. Both halves were re-measured against
`origin/main` at base `24162f95`, through the **real** record validator
imported from the built `dist` of `@objectstack/objectql` — no harness,
no source shortcut.

| case | ruling B requires | measured BEFORE this PR |
| --- | --- | --- |
| fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT |
**REFUSE** `max_scale` `{scale:2, actual:4}` |
| fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE**
`max_scale` `{scale:2, actual:3}` |
| fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT |
**REFUSE** `max_scale` `{scale:3, actual:5}` |
| fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale`
`{scale:0, actual:2}` |
| fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE
(agrees) |
| whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE
| ACCEPT / REFUSE (agrees) |

**Lit control, same instrument, same run** — so the refusals above are a
reading and not a dead instrument: the validator ACCEPTED `0.12` and
`0.5` on the same field, and REFUSED for three *different* reasons —
`max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`,
and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all
refused at `scale + 1` in the same run.

Docblock half, read at source: `FieldSchema.scale`'s `.describe()`
(`packages/spec/src/data/field.zod.ts`) states the 0-100 platform
ceiling and nothing about `percent`; `percentScaleOf`'s docblock
(`packages/spec/src/data/percent-scale.ts`) states the fraction/whole
rule and says nothing about `scale`. **Both halves of the card hold. The
premise is TRUE.**

## Accept-set delta — measured in BOTH directions

Both predicates (current and ruled) were run over an exhaustive corpus
of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale`
values x 13 decimal-place counts.

```
corpus cells: 1950   unchanged: 1914   ADDED (accept set grows): 36   REMOVED (accept set shrinks): 0
declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1
```

- The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes
today stops writing; no stored value is re-read; no migration is
implied.
- Only fraction-stored `percent` moves. `percent` with `max` above 1,
and `number` / `currency` / `slider` / `rating` at every `max`, are
byte-identical in verdict.
- ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was
dispatched as a claim to check; the claim survives the check.

⚠️ **One flag for the contract review, not a re-adjudication.** The
ruling's own Execution section declares `Clause-②: no`. The mechanical
criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is
measurably relaxed, so the conservative routing arm is `yes`. The
declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a
routing difference to be recorded at review, not a change to the ruling.

## What this PR implements

`packages/objectql/src/validation/record-validator.ts` — the `max_scale`
branch gains its percent arm. The fraction/whole split is **read from
the spec's `percentScaleOf`**, not re-derived from `max` at this seam,
so the edit widget, the analytics wire and the validator keep answering
from one source.

The refusal envelope now reports the allowance that was **applied**: on
a fraction-stored `scale: 2` field, `0.12345` is still refused and
reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw
declaration beside a stored fraction's place count would render "must
have at most 2 decimal places (got 5)" on a field that accepts four — a
true refusal described by a false constraint. This is the one detail the
ruling's letter leaves open; it is decided in the direction that keeps
the machine-readable surface honest, and it is pinned.

## The spec half — and the boundary that gated it

The ruling's first bullet is the `packages/spec` `FieldSchema.scale`
docblock **and the field reference page it generates**. That is
`packages/spec/src/data/field.zod.ts`, which was **outside** the
four-file boundary this card was dispatched with, so it was reported
before being touched rather than taken quietly. The two `packages/spec`
paths the dispatch originally named (`numeric-column-representation.ts`
and its test) are about the **DDL column** (`numeric_precision` /
`numeric_scale`) and mention `percentScaleOf` only inside a prose
comment — they are not part of this repair and are untouched.

What landed, after the seat authorized the corrected surface:

- `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s
`.describe()` now states **both** meanings: what the number counts on a
`percent` field (decimal places of the displayed percentage-point value)
and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒
`scale`, every other numeric type ⇒ `scale`). Both halves go in the
**describe**, not only in a source comment, because the reference page
is generated from the describe and an author who reads only that page is
the author the ruling is about.
- `content/docs/references/data/field.mdx`, `data/object.mdx`,
`system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec
gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three
tracked files moved and nothing else**, which is what the pre-commit
source/regeneration split was arranged to make legible: the source edits
were committed first, so the regeneration commit's file list is the
regeneration's own output.
- `packages/spec/src/data/percent-scale.ts` — the optional
cross-reference, **taken**. The card's own measurement table named
*this* docblock as the one silent about `scale`, and `percentScaleOf` is
the function the validator calls, so a reader who lands here should find
the consequence rather than re-derive it. Written as a pointer, ⛔ not a
second copy: the rule is stated once on `FieldSchema.scale` and enforced
once in the validator.

**Serial constraint re-measured for those paths** before any of it was
written, same instrument as the dispatch used: 20 open PRs, `GET
/pulls/N/files` each, **0 unreadable file lists**, and no open PR
holding any of the eight paths. Lit control on the same run:
`packages/spec/src/**` matches 7 open PRs (objectstack-ai#19398, objectstack-ai#19374, objectstack-ai#19373,
objectstack-ai#19335, objectstack-ai#19314, objectstack-ai#19090, objectstack-ai#18319), so the zeros are absences the
instrument could see.

## Verification

**Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2`
in the production file.

- ⚠️ The **first attempt was a no-op and its reading is void**: the
replacement string was a prefix of the anchor, so its occurrence count
could not rise, and the tool refused before running anything. Recorded
rather than quietly retried.
- Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a ->
7b082941b44f`, command executed, restore proved `blob == HEAD
(2e2d398)` with `git diff HEAD` empty.
- Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new
block and fail for the right reason — the fraction-stored writes are
refused with `max_scale`, and the envelope/message read `2` where the
ruling requires the applied `4`.
- ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as
evidence**: the scale+3 refusal, the whole-percent pin, the
other-numeric-types controls, the other-refusal-reasons control and the
no-declared-scale control pass on both trees **by design** — they are
anti-vacuity and lit-control pins, there so that a branch which simply
stopped enforcing `scale` on percent fails this block too.

**Gate family** — derived from the real changed paths with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, every command run with its exit code
captured before any pipe, reconciled with `--ran` carrying the recorded
codes:

```
111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun
```

- One real red was found and repaired in this PR:
`check:error-code-casing` read the envelope pin's bare `code:
'max_scale'` as an ADR-0112 D1 emission because its recognizer window
held no field-addressed neighbour. Naming the field is the repair and a
stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in
6371 scanned file(s)".
- NOT MEASURED, each with its reason, none of them a red:
- `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads
built output, 66 packages have no `dist/` in this worktree.
- `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the
whole workspace closure built.
- `check-plugin-teardown-shape.mjs --self-test` — exit 3: its
positive-control fixture is pinned to a commit this shallow clone cannot
reach.
- `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute
an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits
inside the window. Counted as "run" by the reconciler (exit 2 is not the
prerequisite code) but it measured nothing, so it is reported here as
NOT MEASURED.
- One gate **refused its prerequisite while spelling it `exit 1`**:
`check:skill-examples` reported that `packages/client-react/dist` held
no declarations, which is a refusal and ⛔ not a finding. Rather than
report it as NOT MEASURED, the package closure was built and the gate
re-run to a real verdict: exit 0, **258 prose examples type-check across
3 surfaces**, including the 10 spec-source TSDoc blocks — the surface
this PR edits.
- The two remaining `exit 3` families were **deliberately left
unmeasured**: both need a whole-workspace build, and both are whole-tree
families unrelated to a describe string and a validator arm. CI builds
everything and measures them there. The `check:skill-examples` closure
was built because that gate reads the spec source surface this diff
touches — the choice is principled, ⛔ not a budget.
- The reconciler's own verdict, DERIVED from the recorded codes rather
than claimed: `111 derived famil(ies) accounted for — 108 run, 3
NOT-MEASURED (3 DERIVED from a recorded exit 3)`.

**Suites and lint**, at the final commit:

- `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests
passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0.
- `pnpm --filter @objectstack/objectql typecheck` — exit 0;
`check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors /
65 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15
generated artifacts up to date** against the edited `FieldSchema.scale`.
Both gates the ruling's docblock half puts at risk are green by name:
**`check:docs`** (the three regenerated reference pages) and
**`check:authorable-surface`** (authorable surface + JSON schemas).
`authorable-surface.base.json` did not move — a regular build never
writes it.
- `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at
`bb9f9274`, the final commit. The full union ran; no narrowing was
needed, so no narrowing is claimed.
- **The ablation reading still describes the shipped file**: `git
hash-object packages/objectql/src/validation/record-validator.ts` is
`2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so
nothing landed on the production file after it was proved able to fail.

**Import-side pins**: the public surface of `@objectstack/objectql` is
byte-unchanged (no export added, removed or retyped), so only behaviour
could move a consumer pin. Every non-`objectql` test file mentioning
`'percent'` was checked for a co-occurring `scale`; the six hits are
`packages/spec` schema tests and two `service-analytics` wire tests,
none of which exercises the record validator. The grep returning six
files is its own lit control.

## Acceptance notes

Noted, not filed — neither meets the three filing classes, and the
carrier for each is named:

- The `max_scale` message template
(`packages/spec/src/system/validation-message.ts`) reads "must have at
most N decimal places", which on a fraction-stored percent now describes
the STORED fraction rather than the number the author typed. It is
accurate and it is not what the author sees in the widget. Whether a
percent-specific sentence is wanted is a display decision that belongs
with the ruling's author, not a defect. Carrier: the contract review on
this PR.
- `packages/spec/src/data/numeric-column-representation.ts` already
carries an accurate prose account of the fraction storage rule in its
`percent` entry. It is documentation of the column, not of `scale`, and
needs no change under this ruling. Carrier: none needed — recorded so
the next reader does not re-derive the same dead end.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm


---
_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
…stack-ai#19495)

Fixes objectstack-ai#19490

## What this changes

`scripts/pm/check-clause2-carriers.mjs`'s **C5** limb turned a widening
**tell** into a hard refusal: a card declaring `Clause-②: no` whose diff
carried tells made `--pair N` exit 4, and PD objectstack-ai#14 makes `--pair` at 0 a
conjunct of the landing predicate. C5 is now **report-only** — the row
still prints, with its `file:line` evidence and its remedy text, and it
no longer contributes to a non-zero exit.

**⛔ C5 and nothing else.** C6 stays a hard refusal, as do C2, C3, C8, C9
and the UNJUDGED exit-2 path.

## The ruling this stands on

Maintainer ruling of 2026-09-21, recorded at
objectstack-ai#18917 (comment).
Quoted verbatim, untranslated, because paraphrasing a ruling rewrites
it:

> 阻碍我的pr落地,导致 agent 开发满就是负面因素,哪怕挡住几个bug,但是出现bug也是可以重新修改的。

The operative distinction that record draws, and which the code now
carries in its own words (`pairReportRows`'s docblock):

> A limb that judges 「is this diff widening a contract」 — which
`check-widening-tells` itself says it cannot prove (its header: 「a tell,
never a proof」, 「cannot tell an array element from a call argument」) —
does not hold a hard gate. A limb that asks 「does a review of record
exist」 does.

## The mechanism

- `pairReportRows(pair, repo)` is new and is **where the demotion is**:
the C5 row is built there instead of being pushed into `rows`, and
`rows.length === 0` is the exit-0 condition. A row that is not in that
list cannot refuse.
- `renderPair` is split into the pure **`pairRendering`** (every line,
its stream, and the exit, computed before a byte is printed) and a
printer. That split is what lets the self-test pin what a run **prints**
beside what it **exits** — "the row still prints" and "the exit is 0"
are two claims, and a case pinning only the second stays green over a
row that has gone silent.
- The C5 row's text gains `C5_REPORT_ONLY_DISPOSITION`: it says the row
is report-only, that the at-tier contract review is what rules on the
tell, and ⛔ that report-only is **not** a clearance and **not** a
verdict that the tell is false. `REFUSAL_SENTENCE`'s remedy is carried
verbatim, unchanged.
- The report row prints under `⚑`, never `✗` — a refusal marker beside
an exit of 0 is the exit register's banned "0-with-a-message", inverted.
- `greenPairLine` gains a `wideningReported` branch, so an exit-0 line
on a pair that carries tells says so instead of going quiet. Without it
a 0 would read as "narrow", which is the silence this file exists
against.
- The report and its `file:line` list print on **every** exit this path
can answer — including the exit-2 gap path, where the tell would
otherwise be lost.

## Measured, before and after

Exit codes captured BEFORE any pipe.

| run | before | after |
| --- | --- | --- |
| `--pair 19314` | **exit 4**, sole adverse row `✗ C5`, 7 tells | **exit
0**, row `⚑ C5`, the same 7 tells |
| `--pair 19438` | **exit 4**, row `✗ C6` | **exit 4**, row `✗ C6` —
untouched |

Both halves of requirement 1 verified: 19314 reaches 0 **because C5 no
longer refuses**, not because any tell disappeared. `grep -c 'T2
packages/spec/src/stack.zod.ts'` reads **7** on the before log and **7**
on the after log — `stack.zod.ts:3412` through `:3418`, each with its
`T2` explanation and its `+` source line, in both.

The after run's exit-0 line says so itself, rather than reading clean:

> ⚠️ Its diff DOES carry widening tell(s), printed in full above with
their file:line evidence: since the maintainer ruling of 2026-09-21 row
C5 is REPORT-ONLY and moves no exit code, so this 0 says the clause-②
limbs are LEGIBLE and ⛔ does NOT say the diff is narrow. The at-tier
contract review is what rules on those tells.

## Pins, and the ablation for each

A new self-test battery, `⭐ the 2026-09-21 ruling: C5 REPORTS its tell,
C6 still REFUSES`, 24 cases, declared in `SELF_TEST_BATTERIES` at its
measured floor. Self-test: **1075 → 1099 cases, exit 0**.

Six ablations, each through `scripts/ablation-replace.mjs` so the
mutation's landing is the tool's own verdict (anchor count 1 to 0, blob
hash before and after) and the restore is proved by `blob == HEAD` plus
an empty `git diff HEAD`:

| # | ablation | red cases | what it proves is not vacuous |
| --- | --- | --- | --- |
| 1 | put C5 back into `rows` | 6 | the exit-0 half: "C5 present reaches
0" |
| 2 | delete the `printReports` body | 9 | the printed half: row,
`file:line`, evidence list, remedy, disposition, marker |
| 3 | delete `greenPairLine`'s `wideningReported` branch | 1 | the
exit-0 line says the diff carries tells |
| 4 | delete the `C6` row push in `pairRows` | 14 | C6 still refuses,
and the mixed case's 4 is C6's |
| 5 | make `pairReportRows` return `[]` | 8 | the report exists at all,
including the structural case |
| 6 | make `wideningUnjudged` return `null` | 4 | the UNJUDGED exit-2
path is untouched |

Every ablation restored byte-identically before the next; `git diff
HEAD` empty and `git status --porcelain` clean after each. The two `⛔
CONTROL` cases on the clean-diff pair stay green under all six by design
— they are the non-vacuity bracket for the reported/clean pair of
readings, not pins on code.

## Gates

`node scripts/pm/check-governed-merges.mjs --test
scripts/pm/check-clause2-carriers.mjs` → **exit 0, NOT governed** —
ordinary queue landing applies to a PR with exactly this file list.
Size: 292 changed lines (+260 / -32), under the 5000-line human-merge
threshold.

Every command's exit code captured BEFORE any pipe:

- `node scripts/pm/check-clause2-carriers.mjs --self-test` → 0 (1099
cases)
- `node --check scripts/pm/check-clause2-carriers.mjs` → 0
- `pnpm exec eslint scripts/pm/check-clause2-carriers.mjs` → 0
- `node scripts/check-self-test-wired.mjs` → 0
- `node scripts/check-scripts-symbol-anchors.mjs` → 0
- `node scripts/pm/check-governed-merges.mjs --test` → 0
- plus the full family list derived by `node
scripts/pm/dispatch-gates.mjs --commands` (35 commands, derived from the
merge base, three-dot) — all 0.

## 维护者速读(草稿)

**改了什么** — 把全仓落地谓词里的 C5 一肢从「拒绝」降为「只报告」。一张卡声明 `Clause-②: no`、而 diff
带了扩面迹象时,这条行照旧打印,连同它的 `file:line` 证据和补救文字;但它不再让 `--pair`
返回非零。C6(本轮是否留下达档复核记录)以及 C2/C3/C8/C9、UNJUDGED 的 exit 2 一律原样不动。

**为什么改** — 2026-09-21 的裁决:挡住 PR 落地、让开发 agent 排满,本身就是负面因素,哪怕代价是漏几个 bug,因为
bug 还能再改。裁决给出的分界是问题的**种类**而非严重程度:判断「这个 diff
是不是在扩接口」的肢,其工具自己写着「只是迹象,不是证明」「分不清数组元素和调用实参」,就不该持硬闸;问「有没有一条复核记录」的肢可以。现场证据:`check-widening-tells.mjs`
14 天改了 18 次,挂着 5 张缺陷卡且**全部是误报**;PR objectstack-ai#19314 全绿、达档复核 PASS 已判定它那 7
条迹象**全部为假**,仍被这 7 条卡了 12 小时。

**风险与代价(含回滚)** — 代价是真实的:一个确实扩了接口却声明 `no` 的
PR,机器不再拦它,改由达档复核去判——这正是裁决接受的那笔交易。风险被三处收窄:行照打不误、证据照列、exit-0 那行自己写明「本 0
不代表 diff 是窄的」。回滚成本一行:把 `pairRendering` 里的 `const reports =
pairReportRows(pair, repo)` 改回推进 `rows`,6 条用例会立刻转红提示。本 PR 保持
draft,不合并、不入队、不动标签,等维护者看过。

**席位意见** —

**你要做的** — 读一眼上面的分界是否就是你的本意(C5 报告、C6 仍拒);同意就把这个 PR 标 ready 并走队列。合并后
`--pair 19314` 立刻读 0,那张被挡 12 小时的 PR 就能合法落地,不需要任何人自查放行。

## Acceptance notes

- **No card existed for this when the work started; this session created
objectstack-ai#19490 itself.** The brief that dispatched it predicted issue creation
would be refused. It was not: `POST
/repos/objectstack-ai/objectstack/issues` returned 201. The branch
therefore carries a real `issue-NNNNN` segment and `CLAIM_BRANCH_SHAPE`
is satisfied — the predicted exit-2 UNJUDGED consequence does not apply.
- **`--pair` on THIS PR is adverse on C2, and deliberately so.** Card
objectstack-ai#19490 carries no `Claim:` comment, because writing one is the owning
seat's act and not a dispatched executor's. A seat that wants that
reading to clear posts the claim naming this branch. `scripts/pm/**` is
not a governed surface, so PD objectstack-ai#14's `--pair`-at-0 conjunct does not gate
this PR's landing either way.
- No labels were added or removed, per the brief. `scripts/pm/**` is on
the changeset fast track (not published), so `skip-changeset` applies on
the merits; this PR does not hang that label.
- The demotion broke **no existing self-test case**: the full suite was
green on the edited file before a single new case was added. The old
C5-refuses behaviour was pinned nowhere at the rendering level, which is
itself worth knowing about this file.
- `check-widening-tells.mjs`, `check-half-states.mjs`,
`check-governed-merges.mjs`, `AGENTS.md` and all skill text are
untouched. The tell is not the defect being fixed here — its force is.

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

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

Fixes objectstack-ai#19498

Clause-②: no

Gate weakening is a maintainer floor. The sentence that authorizes this
one, verbatim (ruling objectstack-ai#208 on objectstack-ai#19491, part R4):

> 19491 接受你的建议,并立刻派发处理相关任务。

## What this changes

`Lint & Repo Gates` set the wall clock of PR objectstack-ai#19314's CI — 27.4 minutes
over 184 steps for a three-file `packages/spec` diff, above the longest
test shard — and 18.6 of those minutes were the tooling's own
self-tests, corpora and censuses. The single `PM dispatch-gates
self-test` step was 11.8 of them, on a PR that changes no PM tool: that
family's read-set included the whole-tree censuses its battery runs —
the content of every JS/TS and `.sh` file, the nested `.gitignore`
files, and the tracked NAME set, which made an ADDED path anywhere run
it.

1. **`pm_dispatch_gates` is narrowed to the tool's own inputs**: the
workflow tree and composite actions its discovery reads, every gate
source it resolves under `scripts/` and a workspace package's own
`scripts/`, the `package.json` that names a `check:*` script, the agent
configuration its live cases read (`.claude/**`, `skills/**`,
`AGENTS.md`, `CLAUDE.md`), and root configuration. The self-test's own
refusal semantics are untouched — an unreadable population still
refuses.
2. **Four more tooling steps go behind the selector**, each as a family
with a read-set of its own inputs plus the matching `if:` line in
`lint.yml`.
3. **`push` to main and the hourly scheduled run are unchanged** and
keep the whole battery: the selector runs everything for any event it
does not scope.

## The census: every unconditional step at or above ~0.3 min

Measured on run 35506407130, job `Lint & Repo Gates` (check-run
106066910262) at head `7d67e1ee4136aee8f8e6ea838950c7fb71520be2`, read
step by step from `GET /repos/{owner}/{repo}/actions/jobs/106066910262`.
184 steps, 27.4 min.

| step | min | subject | disposition |
|:--|--:|:--|:--|
| PM dispatch-gates self-test | 11.80 | tooling self-test | already
scoped — **read-set narrowed** |
| Engine query-options erasure ratchet | 2.07 | product ratchet |
unchanged (scoped by its real read-set) |
| ESLint | 1.27 | product lint | stays unconditional (objectstack-ai#16496 card ruling
2) |
| Slot-lookup ratchet | 1.07 | product ratchet | unchanged (scoped by
its real read-set) |
| Comment mask agrees with a real parser over the whole corpus | 0.90 |
corpus agreement | unchanged (already a family) |
| scripts/ entry guards go through one predicate | 0.62 | tooling corpus
over `scripts/**` | **moved** → `entry_guard` |
| Engine test-double contract gate | 0.50 | product gate | stays
unconditional |
| Self-test workflow-command gate | 0.48 | tooling self-test | **moved**
→ `self_test_workflow_commands` |
| A declared gate population reaches the tree | 0.40 | tooling gate over
the derivation | **moved** → `declared_population_live` |
| Checkout repository | 0.40 | runner infrastructure | not a gate |
| ADR anchors + number uniqueness | 0.37 | docs/ADR gate | stays
unconditional |
| Tenant-audit census matches the tree | 0.35 | product census | stays
unconditional (named in the card) |
| PM bare-root worklist self-test | 0.32 | tooling self-test | **moved**
→ `bare_root_worklist` |

Below the line, left unconditional because the list decides and not the
principle: `scripts/ shared-module self-tests` 0.27, `Cross-package test
inputs` 0.25, `Declared registry log level` 0.25, `Platform-object
tenancy census` 0.20, `Merge-driver wiring gate` 0.20, `Documented HTTP
status matches the status the runtime emits` 0.17, `ObjectQL double
limit gate` 0.17, `Changeset-family gate self-tests` 0.15. Every other
step in the job measured under 0.17 min.

Product ratchets and censuses stay unconditional throughout:
`query_options_erasure`, `slot_lookup`, the tenancy and tenant-audit
censuses, the engine gates.

## The four families added, and what each reads

| family | step command | read-set |
|:--|:--|:--|
| `entry_guard` | `pnpm check:entry-guard` | `scripts/**` — its own
`ROOT_DIR_WATCH_HINTS`, held against the root it walks by its own
self-test |
| `declared_population_live` | `pnpm check:declared-population-live` |
imports `discoverFamilies` + `trackedFiles`: the workflow tree, every
gate source discovery resolves, the tracked NAME set (only a name that
DISAPPEARS moves its verdict, and deletions already run everything) |
| `bare_root_worklist` | `node scripts/pm/bare-root-worklist.mjs`
(self-test only) | the same derivation, the same read-set |
| `self_test_workflow_commands` | `node
scripts/check-self-test-workflow-commands.mjs` | its declared
`scripts/**` population of `.mjs`, `.mts` and `.sh` files, plus the
workflow tree and `.github/actions` it discovers the runnable self-tests
from |

## What is weaker now, said out loud

A ratchet's skip says: no changed path is one this family reads. A
tooling self-test's skip now says something weaker: no changed path is
one the **tool's own inputs** name, while the battery behind it may
still read that path through a whole-tree census. So a defect these five
would have caught can first appear on `main` instead of on the PR that
wrote it.

Three things bound that, and none of them changed here: every doubt
still runs everything (unresolvable base, empty diff, unclassified path,
any deletion, rename or type change); `push` on main and the hourly
`schedule` run the whole battery; and widening the skip further is again
a maintainer call. The selector's header carries this paragraph beside
the read-sets, and `lint.yml` carries it on the steps themselves —
including the three steps whose prose used to say "unconditional, like
every self-test around it", which this change would otherwise have made
false.

Two previously pinned cases are given up deliberately and are now pinned
in the other direction, so the loss is legible: an ADDED file anywhere
no longer runs `pm_dispatch_gates` (the tracked-NAME sweep), and neither
does a nested `.gitignore` or a workspace `.sh` outside `scripts/` (the
objectstack-ai#16769 case).

## Acceptance — the four dry runs, verbatim

**(a) `pull_request`, changed files = PR objectstack-ai#19314's list.** Reproduced in
a throwaway detached worktree off `origin/main` (`c9b23cd066`), driven
with this branch's selector; the worktree was removed afterwards and
nothing under `scripts/pm/` is touched by this PR.

```text
-- (a) changed files --
A	.changeset/19150-declares-collection-pipe-authorable-side.md
A	packages/spec/src/compose-stacks-collection-pipe-arm.test.ts
M	packages/spec/src/stack.zod.ts
Gate-family diff base: c9b23cd  (merge-base of origin/main and HEAD)
Gate families: 3 run, 6 skipped  (event: pull_request; changed paths: 3)
  run   slot_lookup            reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace)
  run   query_options_erasure  reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace)
  skip  entry_guard            no changed path is in its read-set
  run   comment_mask_corpus    reads packages/spec/src/compose-stacks-collection-pipe-arm.test.ts (A, workspace)
  skip  pm_dispatch_gates      no changed path is in its read-set
  skip  declared_population_live no changed path is in its read-set
  skip  bare_root_worklist     no changed path is in its read-set
  skip  self_test_workflow_commands no changed path is in its read-set
  skip  verify_lock            no changed path is in its read-set
VERDICT command-exit=0
```

**(b) `pull_request`, changed file `scripts/pm/dispatch-gates.mjs`** —
same throwaway worktree:

```text
-- (b) changed files --
M	scripts/pm/dispatch-gates.mjs
Gate-family diff base: c9b23cd  (merge-base of origin/main and HEAD)
Gate families: 6 run, 3 skipped  (event: pull_request; changed paths: 1)
  skip  slot_lookup            no changed path is in its read-set
  skip  query_options_erasure  no changed path is in its read-set
  run   entry_guard            reads scripts/pm/dispatch-gates.mjs (M, scripts)
  run   comment_mask_corpus    reads scripts/pm/dispatch-gates.mjs (M, scripts)
  run   pm_dispatch_gates      reads scripts/pm/dispatch-gates.mjs (M, scripts)
  run   declared_population_live reads scripts/pm/dispatch-gates.mjs (M, scripts)
  run   bare_root_worklist     reads scripts/pm/dispatch-gates.mjs (M, scripts)
  run   self_test_workflow_commands reads scripts/pm/dispatch-gates.mjs (M, scripts)
  skip  verify_lock            no changed path is in its read-set
VERDICT command-exit=0
```

**(c) `push` — every family runs:**

```text
Gate families: 9 run, 0 skipped  (event: push; changed paths: 0)
  run   slot_lookup            event 'push' is not scoped -- the full battery runs
  run   query_options_erasure  event 'push' is not scoped -- the full battery runs
  run   entry_guard            event 'push' is not scoped -- the full battery runs
  run   comment_mask_corpus    event 'push' is not scoped -- the full battery runs
  run   pm_dispatch_gates      event 'push' is not scoped -- the full battery runs
  run   declared_population_live event 'push' is not scoped -- the full battery runs
  run   bare_root_worklist     event 'push' is not scoped -- the full battery runs
  run   self_test_workflow_commands event 'push' is not scoped -- the full battery runs
  run   verify_lock            event 'push' is not scoped -- the full battery runs
VERDICT command-exit=0
```

**(d) the selector's self-test** (`pnpm check:select-gate-families`),
which also pins the YAML half against the real `lint.yml`:

```text
  ok    the workflow scopes exactly the families the script decides (9)
  ok    each family gates exactly one step
  ok    pm_dispatch_gates gates the step running: pnpm check:pm-dispatch-gates
  ok    query_options_erasure gates the step running: pnpm check:query-options-erasure
  ok    slot_lookup gates the step running: pnpm check:slot-lookup
  ok    entry_guard gates the step running: pnpm check:entry-guard
  ok    declared_population_live gates the step running: pnpm check:declared-population-live
  ok    bare_root_worklist gates the step running: node scripts/pm/bare-root-worklist.mjs
  ok    self_test_workflow_commands gates the step running: node scripts/check-self-test-workflow-commands.mjs
  ok    verify_lock gates the step running: bash scripts/pm/os-verify-lock.sh
  ok    comment_mask_corpus gates the step running: node scripts/check-comment-mask-corpus.mjs
all 44 cases passed (228 checks)
VERDICT command-exit=0
```

The battery grew from 30 cases / 120 checks at its floor to 44 / 228,
and the floor moves with it (42 / 220). New cases: the nine ids in job
order, an ADDED path inside a read-set, a composite action, the PR
objectstack-ai#19314 shape under both `merge_group` and `pull_request`, and one
`pin_step` per new family.

For completeness, this PR's own diff under `pull_request` — a change to
the selector and the workflow runs all five tooling families:

```text
Gate-family diff base: 2cac363  (merge-base of origin/main and HEAD)
Gate families: 5 run, 4 skipped  (event: pull_request; changed paths: 3)
  skip  slot_lookup            no changed path is in its read-set
  skip  query_options_erasure  no changed path is in its read-set
  run   entry_guard            reads scripts/ci/select-gate-families.selftest.sh (M, scripts)
  skip  comment_mask_corpus    no changed path is in its read-set
  run   pm_dispatch_gates      reads .github/workflows/lint.yml (M, workflow)
  run   declared_population_live reads .github/workflows/lint.yml (M, workflow)
  run   bare_root_worklist     reads .github/workflows/lint.yml (M, workflow)
  run   self_test_workflow_commands reads .github/workflows/lint.yml (M, workflow)
  skip  verify_lock            no changed path is in its read-set
VERDICT command-exit=0
```

## Expected wall clock

The five steps carry 13.62 min of the 27.4-min job (11.80 + 0.62 + 0.48
+ 0.40 + 0.32). On a diff of objectstack-ai#19314's shape all five skip and nothing
else changes, so `Lint & Repo Gates` should read about **13.8 min** —
arithmetic on one run's step timings, on that runner with its cache
state, not a prediction of the next run. The card's bar is ≤ 16 min, and
the director seat measures the real number on the first product PR after
this lands.

## Tier S — not on the governed register

```text
$ node scripts/pm/check-governed-merges.mjs --branch claude/issue-19498-self-tests-off-pr-path
derived from `git diff --name-only --no-renames 2cac363 9a1ca2b` (three-dot): 3 path(s).
    origin/main = 48c39e0, claude/issue-19498-self-tests-off-pr-path = 9a1ca2b, merge-base = 2cac363.
    size: +333 / -111 over 3 file(s) (0 binary, counted 0) — `git diff --numstat --no-renames` on the same range.
governed-surface predicate: 0 of 3 path(s) hit the register (6 surfaces, repo-agnostic).
  ✅  NOT governed — ordinary queue landing applies to a PR with exactly this file list.
  size: 444 changed line(s) (+333 / -111) ≤ 5000 — under the human-merge threshold (generated files included in the count).
VERDICT command-exit=0
```

## Gates

`skip-changeset`: nothing under `packages/**`, nothing published.

Every family `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives for this diff was run locally, each
exit code captured before any pipe — 51 commands, 50 exit 0, including
`check:self-test-wired`, `check:self-test-workflow-commands`,
`check:step-collectors`, `check:declared-population-live`,
`check:entry-guard`, `check:watch-hint-literal`,
`check:required-contexts`, `check:workflow-status-functions`,
`check:ci-filter-parity`, `check:bash32-floor` and `check:nul-bytes`.
`pnpm check:pm-dispatch-gates` was run detached per its own header and
passed: `✓ dispatch-gates self-test: 1883 cases pass.` / `the battery
took 1056.1s on this box.`

The one command that did not return a verdict: `pnpm
check:type-check-debt` exits **3 = PREREQUISITE NOT MET** in a fresh
worktree (`--re-measure cannot run: 31 workspace dependenc(ies) of the
ledgered packages have no built type entry point on disk`), which its
own remedy text declares is neither a pass nor a finding. This diff
touches no TypeScript, and CI builds the closure before that step.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… questions only; C5 and the patrol anchor stop blocking; at-tier review scoped to what ships and read from CI (objectstack-ai#19496) (objectstack-ai#19513)

Fixes objectstack-ai#19496

Clause-②: no

**Tier H — the maintainer merges this by hand.** The diff touches
`AGENTS.md` and `.claude/**`, so one Tier H path makes the whole PR Tier
H: no seat flips it ready, queues it, or arms auto-merge, and no agent
account approves it.

Authorization, verbatim and untranslated — maintainer, 2026-09-21
(ruling objectstack-ai#208 on objectstack-ai#19491):

> 「19491 接受你的建议,并立刻派发处理相关任务。」

## What lands — five charter edits, text only

Nothing under `scripts/`, `.github/` or `packages/` is touched. The C5
code demotion, the patrol schedule and the CI self-test scoping are
their own cards.

**R1 — a non-zero `--pair` blocks landing only on rows that answer a
definite question.** `SKILL.md` 〈入队与落地〉 and
`references/contract-review.md` 落地前检三条:

```text
- `Clause-②: yes` 认领同笔卡上挂标;开 PR 跑 `--pair N`:只确定性行红才挡请审,C5 只印读数。
- 0 = 确定性行全清;4 = 任一不成立,只确定性行红才挡落地;3 = 环境答不了 ⛔ 不作干净。
- 确定性行 = 记录在案、`Served-tier:`、双载体一致、认领形;C5 放宽 tell 只报告,归复核裁。
```

`AGENTS.md` Prime Directive objectstack-ai#14 is where the conjunct lives — `git grep
-n -- '--pair' AGENTS.md .claude` is the census (1 hit in `AGENTS.md`, 5
in `.claude`). Tier S now reads `reads 0 on its definite rows (⛔ never
C5) and every check is green`; the paragraph was re-wrapped from that
sentence onward and `AGENTS.md` stays at 1109 lines.

**R2 — the rule line**, next to the tooling rules ruling objectstack-ai#202 B landed,
in 〈分诊座位职责〉's filing classes:

```text
- 只报告的仪器,报错不配 dev:猜意图的只印读数,误报席位一句推翻,⛔ 不立卡不派 dev。
```

**R3 (charter half) — the patrol anchor stops being a precondition.**
The two 〈执行座位职责〉 lines are deleted; one line replaces them, mirrored in
`references/core-rules.md`:

```text
SKILL.md      - 半状态巡查按需跑(分诊席每日对账可调),H 行是读数不是前提;⛔ 不因锚行停派发。
core-rules.md - 半状态巡查按需跑,其 H 行是读数不是前提,⛔ 不因锚行停派发。
```

**R5 — at-tier review: scope and shape**, in
`references/contract-review.md` 〈复核归属与资格〉:

```text
- 复核面 = 出货给用户或 agent 的:`content/docs/**`、`apps/docs/**`、CHANGELOG/`.changeset` 散文。
- 同含已发布 schema 与 governed 规则文本;三面皆不碰 ⇒ CI 加席位自读,⛔ 不起第二个 agent。
- 复核形状:只读 diff 与卡片,check 结论取 head 的 check-runs,⛔ 永不本地重跑派生门禁族。
```

`Served-tier:` and the record shape are unchanged.
`check-clause2-carriers --template` prints nothing that contradicts the
shape: its ③ is the record's boundary-flag heading and it prescribes no
local gate run, so no script edit was needed (checked; reported below as
a reading, not a finding).

**R6 — 〈仪器纪律〉** is a new reference,
`references/instrument-discipline.md` (11 lines), with exactly one
pointer line in SKILL.md 〈平台读数纪律〉, placed one line below the
advisory-red rule:

```text
- 仪器纪律(硬门禁面、只报告面、新增授权、工具位)见 `references/instrument-discipline.md`。
```

The section itself:

```text
- 硬门禁只答有确定答案的问题:受管登记表、队列守卫、CI 测试、复核记录在不在。
- 判意图的仪器(放宽 tell、半状态巡查)只印读数 ⛔ 不挡落地,误报由席位一句话推翻。
- 放宽 tell(C5)由 `scripts/pm/check-widening-tells.mjs` 印 file:line,归达档复核裁。
- 只报告的仪器不配 dev:⛔ 不立卡、不派 dev、不开 PR;它的在途工作只有删除。
- 新增门禁、巡查行或棘轮须在卡上引维护者原话,⛔ 无原话不新增;首行四件恒硬。
- 工具位只有一个,先花在删除上;`dispatch-gates.mjs` 冻结,只在它喂的 workflow 坏了时碰。
- 出处:维护者 2026-09-21 逐字「19491 接受你的建议,并立刻派发处理相关任务。」
```

**Why a new file rather than `landing-operations.md`.** Every file an
at-tier reader already opens for landing or gates stands at its ceiling
with zero headroom — `landing-operations.md` 69/69, `true-green.md`
32/32, `review-checklist.md` 77/77 — and the section costs 11 lines, so
hosting it in one of them means deleting live rules, which this card
forbids. The pointer instead sits where the reader deciding what a gate
reading means already is: 〈平台读数纪律〉, right after the two lines on a gate
job's conclusion and an advisory red. The new file carries no `CEILINGS`
row, because that row is an edit under `scripts/**` (out of scope here)
and R6's own rule says a new ratchet needs the maintainer's sentence on
its own card — reported below.

## The line ratchet — no ceiling raised, every added line paid in place

`pnpm check:pm-skill-ratchet` exit 0. Per-file counts on head
`fec2177089`, after merging `origin/main`:

| file | lines | ceiling | headroom | net this PR |
|:--|--:|--:|--:|--:|
| `.claude/skills/pm-dispatch/SKILL.md` | 819 | 819 | 0 | 0 |
| `.claude/skills/pm-dispatch/references/contract-review.md` | 60 | 60 |
0 | 0 |
| `.claude/skills/pm-dispatch/references/core-rules.md` | 151 | 151 | 0
| 0 |
| `AGENTS.md` | 1109 | 1109 | 0 | 0 |
| `.claude/skills/pm-dispatch/references/instrument-discipline.md` | 11
| none | — | new file |

What paid for the added lines — de-duplication only, no rule deleted,
each surviving carrier named:

- SKILL.md 〈复核〉's two 受管面两层 lines became one. The Tier H enumeration it
dropped is the 〈复核〉 line three above it (governed 面统一定义), and 四件套 is
stated at 路径面命中规则层 ⇒ ACCEPT 换终局四件套.
- contract-review.md's 双载体同笔挂 line is stated in SKILL.md 〈入队与落地〉
(`needs:contract-review`(恒英文)由席位同笔挂:PR 一现即挂 PR;报告先到则先挂卡); its unique
tail, ACCEPT 补齐 PR 侧, rides the next line.
- contract-review.md's two `Implemented-by:` / `Reviewed-by:` spelling
lines became one pointer at `--template`, which prints both fields
verbatim — and the record-shape line three above already cites that
template.
- contract-review.md's standalone Tier H/S landing line left; the tier
outcome now rides the 落地前检三条 line itself, so
`references/lanes/director.md`'s pointer at this block still resolves,
and SKILL.md 〈复核〉 carries the full two-tier rule.
- `AGENTS.md`: 43 added bytes absorbed into the paragraph's own slack by
re-wrapping from the edited sentence onward; no line was bought.

## Collision — both charter PRs merge clean

- **PR objectstack-ai#19462** (`claude/issue-19457-charter-product-only-queue`, head
`36ab00aa`) **merged into `main` during this round.** `git merge-tree
--write-tree 36ab00a HEAD` was exit 0 with zero conflict markers before
that, and this branch then merged `origin/main` (`48c39e00`) with no
conflict. Its `tooling` label rules, its two gate-false-positive lines
and its ratchet bump (SKILL.md 813 → 819) are all present on this head.
- **PR objectstack-ai#19488** (`claude/issue-19483-feature-axis-charter`): the card
named head `420bd091`; the branch tip is now `16418377`. `git merge-tree
--write-tree 1641837 fec2177` → **exit 0**, tree
`02a7323a2d779974bd035082ad954eaf4cd15a21`, zero conflict markers. Every
line that PR touches is untouched here.

## Acceptance

| grep | result |
|:--|:--|
| `git grep -n '0 才请审' .claude` | 0 hits (exit 1) |
| control `git grep -n -- '--pair' .claude` | 5 hits (os-dev.md 1,
SKILL.md 1, contract-review.md 2, platform-readings.md 1) |
| `git grep -n '锚行未处置' .claude` | 0 hits (exit 1) |
| control `git grep -n '半状态' .claude` | 24 hits across 8 files |
| `git grep -n 'C5' …/references/contract-review.md` | 1 hit, naming it
只报告 |

## Gates

Derived on this diff with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` — 23 commands; the derivation also
names 11 wide-population, 53 artifact-roster, 14 pending-changeset and 2
CI-valued families as outside that list, so this is not a complete
account of CI. Every exit code was captured **before** any pipe (`cmd >
log 2>&1; e=$?`). Reconciled with `--ran`: **23 derived, 23 run, 0
NOT-MEASURED, 0 UNRUN.**

```text
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/pm/check-governed-queue-guard.mjs --self-test :: exit 0
node scripts/pm/check-harness-current.mjs --self-test :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:docs-audit-scope :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:pm-expected-skips :: exit 0
pnpm check:pm-governed-merges :: exit 0
pnpm check:pm-governed-prose :: exit 0
pnpm check:pm-half-states :: exit 0
pnpm check:pm-skill-id-lint :: exit 0
pnpm check:pm-skill-ratchet :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:required-contexts :: exit 0
pnpm check:skill-frame-sync :: exit 0
pnpm check:watch-hint-literal :: exit 0
```

Three more, run because this card names them:

```text
pnpm check:pm-widening-tells :: exit 0      (self-test only — 525 cases; checker health, not a verdict on this diff)
node scripts/pm/check-widening-tells.mjs --declaration no --diff pr.diff :: exit 0   (a real verdict: no tell)
pnpm check:pm-label-desc-cap :: exit 0
pnpm check:pm-settings-deny-roster :: exit 0   (its roster lives under .claude, which this diff is in)
```

Two gates first answered `exit 3` (PREREQUISITE NOT MET) in a fresh
worktree and were re-run after `pnpm install`, and
`check:doc-formula-expressions` after `pnpm exec turbo run build
--filter=@objectstack/formula --filter=@objectstack/lint` under the
shared verify lock (VERDICT command-exit 0). Those 3s are recorded as
what they are — nothing measured, not a finding.

`skip-changeset` applies: the diff is `.claude/**` plus `AGENTS.md`,
nothing under `packages/**`, and no published `files[]` content moves.

## Acceptance notes — out of scope, not filed by the dev

- SKILL.md 〈状态模型〉 still says 派发与折叠检查时读半状态巡查锚的 H17 触发文件索引. With the
patrol's schedule retired on its own card, that index can go stale; this
card's R3 scope was the two 〈执行座位职责〉 lines only. Dedupe words: 半状态巡查锚,
H17, 触发文件索引, 折叠检查.
- `references/instrument-discipline.md` carries no `CEILINGS` row, so it
is the one un-ratcheted file on the pm-dispatch surface. Adding the row
is an edit under `scripts/**`, and by R6's own rule a new ratchet needs
the maintainer's sentence on its card. Dedupe words: CEILINGS,
instrument-discipline, ratchet row, un-ratcheted reference.
- `check-clause2-carriers --template` prints no line contradicting R5's
shape (checked; no script edit).

## 维护者速读(草稿)

**改了什么** — 把「仪器」这件事写成纪律:`--pair`
这类硬门禁只在能给出确定答案的行上挡落地(复核记录在不在、档位行在不在、两个标签载体一不一致、认领形对不对);猜意图的那一行(C5 放宽
tell)从此只印读数,由达档复核的人判。半状态巡查从「每轮派发的前置条件」降为「按需跑的读数」。达档复核的范围收到「会出货给用户或 agent
的东西」,形状收到「读 diff 加读 CI 的 check-runs」,不再本地重跑门禁。新增一页〈仪器纪律〉,把这几条连同「只报告的仪器不配
dev」「新增门禁要维护者一句话」「工具位先花在删除上」写在一起。

**为什么改** — 裁决 objectstack-ai#208 的实测:工具链自己占了三到四成的合并量、每个 PR 三分之二的 CI 关键路径;把 objectstack-ai#19314
挡住的那道门,本体只是两句章程话,不是 CI 门禁。一次达档复核 24 万 token、29 分钟,其中最大一块是在本地重跑 CI 已经跑过的
37 个门禁族。这三件都不是删代码能解决的,是纪律写错了地方。

**风险与代价(含回滚)** — 代价是硬门变软:C5 不再挡人,漏网要靠复核的人看见。回滚是一次
revert,因为全是文本。棘轮一行没抬,新增的行全部用去重付账,幸存载体逐条点名在上面;唯一的新面是那一页新文件,它暂时没有棘轮行。

**席位意见** —

**你要做的** — 读这五处改动,同意就人工合并(Tier H,队列与 auto-merge 都不适用)。

## Round 2 (seat's note)

Head `96774e44ef`. The at-tier review (5755678024, FAIL) named three
items; all fixed in one push: R5's obligation now keys on the **face**,
not the lane (`references/contract-review.md` 〈复核归属与资格(按面)〉 lines 24–27,
SKILL.md line 647, plus the two lane-keyed twins at SKILL.md 533 and
core-rules.md 113 — 「三面」 is now 「五面」 and the published schema names its
path `packages/spec/src/**` non-test); `origin/main` (`ea64bbc6e8`)
merged with the SKILL.md 180/181 conflict resolved keep-both;
`instrument-discipline.md` cites ruling 208 / card 19491 / comment
5755284987 with bare ids (the `#` spelling is what
`check:pm-skill-id-lint` refuses). Ratchet: SKILL.md 815/819,
contract-review.md 60/60, core-rules.md 151/151, AGENTS.md 1109/1109.

CI: this head has zero GitHub-Actions runs — a `.claude/**` plus
`AGENTS.md` diff matches no PR-level workflow paths; the merge queue's
`merge_group` run supplies the required contexts, so the empty check
list is the known shape and not a stall.

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


---
_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
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

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

---
_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 size/m tests tooling

Projects

None yet

3 participants