spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) - #19147
Conversation
…onfigSchema.assignments Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…dd tests Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…cord-key-preparse-guard
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1782a33e51cb0df868673e04b39cd41b823d82a1 && git checkout 1782a33e51cb0df868673e04b39cd41b823d82a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adf4b18777d507236cd24b7ed59b45a7c71bd1fd 4cdba204156b06cef828319a8c75f284b49ad0cf && git checkout -B drift-repro adf4b18777d507236cd24b7ed59b45a7c71bd1fd && git merge --no-ff 4cdba204156b06cef828319a8c75f284b49ad0cf
node scripts/docs-audit/affected-docs.mjs --json adf4b18777d507236cd24b7ed59b45a7c71bd1fd
|
|
One check went red and is green again; the cause was the dispatching seat's own instruction, not this branch's code. Seat
⭐ The cause is the seat's dispatch word (5736756482), which told the dev to put both closing keywords in the body without putting #18847 into the state that ownership implies. ⇒ Fixed at the STATE end: #18847 went through the full claim protocol (labels and assignee written first, then claim comment 5737404977 naming this branch, ⛔ What was NOT done, and will not be: the gate was not weakened, no check was skipped or quarantined, the
Generated by Claude Code |
Contract reviewServed-tier: 85/85 Reviewed against the maintainer's ruling of record, comment 5725370319 (batch #154 item 1, letter A, narrow); ruling 甲 (5713646497) is withdrawn and option 乙 ruled out. The implementing dev delivered no report, so every reading below is off the diff ① Derived judgmentsDeclared
② Semver level
③ Boundary flags⭐ The ratchet line — advice for the maintainer's hand, not settled here.
Other flags
Implemented-by: VERDICT: PASS On the contract questions: the narrowing is the ruled one at both slots, the guard refuses rather than repairs, the changeset sentence is true of what shipped, and Generated by Claude Code |
…cord-key-preparse-guard Resolves the sole conflict in packages/spec/dropped-refinements.baseline.json (hand-edited, no gen: script — see scripts/lib/dropped-refinements.ts). The `entries` map merged cleanly with no textual conflict (main's #19137 removed two entries; this PR's site renames/additions touched a disjoint set). The `measured` header conflicted and is rewritten to exactly what `pnpm --filter @objectstack/spec gen:schema` reports on the merged tree: publishedSchemasWithDroppedRefinements 200, droppedRefinementSites 560, refinementSitesThatDidProject 357, refinementSitesWithNoJsonFormToCompare 9. The dropped-refinements gate embedded in build-schemas.ts passed with no undeclared/miscounted/repaired/vanished/unreasoned entries on the first run.
Round 3 conflict resolution. The only real conflict was the measured header block in packages/spec/dropped-refinements.baseline.json (this ledger is hand-edited, carries no gen: script, and is not on the merge=os-regen list). The four measured numbers are a build output, never picked from either side or computed by arithmetic, so this commit lands them as placeholder zeros; a follow-up commit on this merged tree re-runs the repo's own measurement (pnpm --filter @objectstack/spec gen:schema) and writes back the numbers it prints. entries merged with zero textual conflict.
Discharges the os-regen deferral recorded by the prior merge commit. pnpm --filter @objectstack/spec gen:schema on the merged tree (HEAD is now the merge commit, so this reads the correct merge-base) reports: 569 refinement site(s) across 204 published schema(s) reach the RUNTIME and not the published JSON Schema; 357 refinement site(s) DID reach the file; 9 had no JSON form on either side to compare. Those four numbers replace the placeholder zeros in packages/spec/dropped-refinements.baseline.json's measured header. entries needed no changes: the gate reported zero undeclared, miscounted, repaired, vanished or unreasoned sites on this run. check:authorable-surface (same script, --check mode) independently reconfirms 204/569/357/9. content/docs/references/data/object.mdx is regenerated via gen:docs from the rebuilt json-schema/ tree (it renders from that gitignored directory, which a merge cannot bring in a text merge).
Contract reviewServed-tier: 47/47 Re-review of record for the head that moved after comment 5737516015 (PASS on ① Derived judgmentsThe delta is main arriving plus one re-measured ledger; nothing PR-authored moved. The earlier PASS survives on this head.
② Semver levelUnchanged and still correct: The ledger, taken here per entry at all three anchors —
Header equals sum on all six ledgers. The 9 new sites are the same 9 schemas at every anchor — Is the +9 the ruling's line-11 cost? Advice to the seat, in two halves. (a) Not literally, but covered in substance. Line 11 says 「the guard does not project into the published JSON Schema」. The guard is a (b) What the delta changed — material to the advice, not to the verdict. The earlier record said the only way to hold the ledger flat was a negative-lookahead regex. That is no longer true on this head. Main's ③ Boundary flags
Implemented-by: VERDICT: PASS On this head: nothing PR-authored moved between Generated by Claude Code |
…nfig (the catchall site) (objectstack-ai#19419) Fixes objectstack-ai#19151 Clause-②: yes (narrowing)⚠️ **This diverges from the claim comment, deliberately and on the record.** Claim 5751323556 declares `Clause-②: no`; the criterion as I read it says `yes (narrowing)`. What lands here is a **refusal newly added to a published parse surface** — the same act, on the same family, that the sibling PR objectstack-ai#19147 declared `Clause-②: yes (narrowing)` in `.changeset/17852-record-proto-key-preparse-guard.md`. Grading a sibling site differently from its family is how a family stops being one, and of the two possible errors, declaring `no` on a real narrowing is the one that lets a contract narrowing land without contract review. The seat owns the correction if it reads the criterion the other way; I write this line once, here, and nowhere else. --- ## STEP ONE — the vendor line, re-read first-hand. It holds. The card's premise arrived half second-hand, so this was the gate before any edit. **Which zod, and how it was resolved.** `packages/spec/package.json` declares `"zod": "^4.4.3"`. Resolved from the package's own entry rather than from the manifest text: ``` node -e "const {createRequire}=require('module'); const r=createRequire('.../packages/spec/src/index.ts'); const p=r.resolve('zod/package.json'); console.log(p, require(p).version);" => /home/user/objectstack-issue-19151/node_modules/.pnpm/zod@4.4.3/node_modules/zod/package.json 4.4.3 ``` `pnpm --filter @objectstack/spec why zod` reports **`Found 1 version of zod`** — 4.4.3 — so the resolution is not one of two. (The lockfile does carry a second, 4.6.1, reached only through the better-auth family; `packages/spec` never sees it.) **The line, at the cited coordinates.** `node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/schemas.js`, `handleCatchall` opens at 759 and the skip is at **767-769**, exactly as reported: ```js function handleCatchall(proms, input, payload, ctx, def, inst) { // 759 ... for (const key in input) { // skip __proto__ so it can't replace the result prototype via the // 767 // assignment setter on the plain {} we build into // 768 if (key === "__proto__") // 769 continue; // 770 if (keySet.has(key)) continue; ... const r = _catchall.run({ value: input[key], issues: [] }, ctx); ``` `grep -n '__proto__' v4/core/schemas.js` returns exactly two sites in the file: **767-769** here, and **1496** in `$ZodRecord`'s open-key branch — the one objectstack-ai#17852 measured and PR objectstack-ai#19147 guarded. One function apart, same shape, and the `continue` sits above the schema that would judge the key in both. ⇒ **`premise_still_valid: true`.** The card's quotation was not accepted as evidence; it was reproduced. ## The defect, reproduced end to end on this tree At `origin/main` `0870fb5418`, through the real exported schema: ``` input (JSON.parse) own enumerable keys : [ 'total', '__proto__', 'other' ] AssignmentConfigSchema.safeParse => success: true parsed own keys : [ 'total', 'other' ] ``` with two lit controls in the same run: the identical config **without** `__proto__` round-trips both keys (so the instrument can see keys at all), and the same `__proto__` **inside** `assignments` is already refused loudly by objectstack-ai#19147's record guard (so the instrument can see a refusal). `JSON.parse` is what makes `__proto__` an own enumerable key; an object literal's `{ __proto__: … }` sets the prototype and never reaches either loop. This lands on data an author wrote on purpose: the schema's own docblock says its top-level keys may be flow variables, and the descriptor declares `additionalProperties: true`. ## The fix `refuseCatchallProtoKey` in `packages/spec/src/shared/record-proto-key-guard.ts` — a **sibling** of `refuseRecordProtoKey`, both now calling one private `refuseProtoOwnKey`. Identical mechanism, identical refused name, identical issue shape (`custom`, `path: ['__proto__']`). `refuseRecordProtoKey`'s message bytes and behaviour are unchanged. Why a second wrapper rather than a second call site of the first: the refusal **names the parser that would otherwise drop the key**, and here that is `.catchall()`, not `z.record()`. An author told their top-level flow variable was dropped by "z.record()" would go looking at the `assignments` map — a different slot, one level down, with a different guard. A pin asserts the two messages name their own parser and not the other's. ## Why a pre-parse guard — the two alternatives, eliminated by measurement 1. **A key/catchall schema cannot see it.** The `continue` at 769 is above `_catchall.run`, so no catchall — not even `z.never()`, whose `unrecognized_keys` list is built inside the loop the `continue` already left — ever receives the key. Same structural unreachability the record guard's docblock records. 2. **Declaring `__proto__` in the object's own shape refuses every config.** Measured: zod reads a declared key as `input["__proto__"]` and tests presence as `"__proto__" in input`; on an ordinary object both answer through the **inherited accessor**, so the value is `Object.prototype` and the key is always "present". A plain config with no `__proto__` authored came back `success: false` with an `invalid_type` at `['__proto__']`. (It is also unwritable as an object literal at all — `{ __proto__: schema }` sets the shape object's prototype rather than adding a key, measured: the shape had one key, `assignments`.) That leaves the raw input, ahead of the parse. ## Why `__proto__` only — re-derived, not copied The record guard refuses `__proto__` alone on the ground that `constructor` and `prototype` reach the key schema unskipped. That ground had to be re-established at this position, because it is a different loop. Measured at the catchall, top level: | authored top-level key | parse | key in the output | |---|---|---| | `constructor` | success | kept | | `prototype` | success | kept | | `toString` | success | kept | | `__proto__` | success | **dropped** | ⇒ the reasoning transfers exactly, and for the same reason it was true below: only `__proto__` is structurally unrepresentable. Everything else round-trips, so refusing it here would be a narrowing no ruling ordered. The `assignments` slot keeps its own guard; the two are different parsers at different depths and neither covers the other. ## The pins, and their ablation The sharpest pin asserts **behaviour**, through one `classify()` helper that discriminates the three outcomes an authored key can meet — `refused` / `silently-dropped` / `silently-kept`. A bare `success === false` would pass for a schema that refused every config; a bare key check would pass for one that kept the key and reported success. Both the defect and its over-correction are named, not assumed. Beside them: an unguarded-object CONTROL that must stay `silently-dropped` on this exact zod; a preservation row per reserved-looking name; the previously-accepted shapes (empty config, bare legacy config, the CEL envelope and its malformed counterpart); the `assignments` guard and the array-form prescription still firing at their own paths; and an invariance pin on the JSON projection. **Ablation** (`scripts/ablation-replace.mjs`, anchor declared and hit exactly once, mutation verified against the disk): ``` anchor x1 -> x0 ; blob 50724ef -> 21c448025a16 (mutation landed) result Tests 6 failed | 88 passed (94) restore blob after restore 50724ef == blob at HEAD 50724ef, `git diff HEAD` empty ``` Direction observed: **red**, as expected. The six that turn red are exactly the six `objectstack-ai#19151` assertions. **The `objectstack-ai#17852` / `objectstack-ai#18847` record-guard pins stay green under the same mutation** — which is the pin that the two guards are independent, and that these six are not riding on the other one's work. The fix was committed before the ablation, so the restore leg points at a commit that really exists; the subject resolves through the package's own `src` (a same-package relative import), so no `dist` leg is involved and none is claimed. ## What else moved, and why `packages/spec/dropped-refinements.baseline.json` — the guard wraps the object in a `z.preprocess` pipe, so the `AssignmentValue` refinement the JSON projection already dropped sits one segment deeper: `assignments.out.valueType` becomes `out.assignments.out.valueType`. **Same single site, same gap, no new one.** The build gate caught it and printed the corrected entry verbatim; this is that entry. Nothing else regenerated: `pnpm --filter @objectstack/spec check:generated` reports **all 15 generated artifacts up to date**, and the JSON projection is byte-identical to the pre-change baseline — same `type`, same single `properties.assignments`, same `xExpression: 'value'` on the map value, same `additionalProperties`. The expression ledger still derives `assignments.*` through `getSchemalessNodeConfigJsonSchemas()`, because every spec walker resolves a preprocess pipe to its OUT side (`pipeAuthorableSide`). All four are pinned, not merely observed. ## Verification Every exit code captured before any pipe. | what | verdict | |---|---| | `pnpm --filter @objectstack/spec build && … check:generated` | exit 0 — all 15 artifacts current | | `pnpm --filter @objectstack/spec test` | exit 0 — **504 files / 14754 tests** | | `pnpm --filter @objectstack/spec typecheck` | exit 0 (test layer included) | | service-automation reconciliation suites (8 files: form↔Zod ledger, expression ledger, config parse/schemas/unknown-keys, assignment envelope ×2, logic nodes) | exit 0 — 117 tests | | `dispatch-gates --commands` then `--ran` | **81 derived, 81 run, 0 UNRUN** | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 | Three of the 81 answered **exit 3 — PREREQUISITE NOT MET, which is not a red and not a pass**: `check-plugin-teardown-shape --self-test` (its positive control is pinned to a commit outside this shallow clone), `check:dual-build-cjs-loads` and `check:type-check-debt` (both read a whole-repo `dist/` this box did not build within the foreground cap). CI builds and runs all three. Two families were derived from a base four commits behind `origin/main` and are named rather than assumed: `check:merged-result` and `check:issue-citations` were wired into `lint.yml` after this branch's base. Both were run anyway — green, after the citation-spelling correction described below. Measured for the changeset's disposition: **zero** authored use of `__proto__` as a top-level key on an `assignment` node config, across this repo, `examples/` and the `objectui` sibling — against a **lit control of 100 authored `assignment` node declarations in 26 files here and 11 files there**. The census is a working-tree reading at `1418799698`, not a history question; this clone is shallow (boundary `ae8edd2c4f71d6f6fea5261e8284997f5546392f`) and no count here depends on history. ## Acceptance notes — noted, not filed 1. **`scripts/check-issue-citations.mjs` cannot resolve a same-repo citation written `objectstack#N`.** `buildBoard` builds its probe set as `rows.filter((r) => !r.qualifier)`, so every **qualified** citation is excluded from the probe — while `classifyCitation` treats `objectstack#N` as naming this repo and resolves it against that same board. The number is therefore never on the board and always reports `allocated-but-absent`. Two-leg measurement on this diff, same six citations, same run mode: with `objectstack#17852` → `board: probed (1 citations)`, `2 allocated-but-absent`, exit 2; with `objectstack-ai#17852` → `board: probed (2 citations)`, `6 resolves`, exit 0. Independent control: `GET /repos/objectstack-ai/issues/17852` answers **HTTP 200** (state `closed`), so the number resolves and the gate's own transport would have found it had it asked. This diff's added citations use the bare spelling, which is this repo's documented form for its own issues and what the gate's failure text itself prescribes. The gate is not otherwise touched here. 2. **`treeifyError` / `error.format()` throw on any issue path containing `__proto__`.** Reproduced first-hand against objectstack-ai#19147's landed record guard on zod 4.4.3: both throw `TypeError: Cannot read properties of undefined (reading 'push')` on the `['assignments','__proto__']` path, while the same call on an ordinary refusal path succeeds (lit control). This guard uses the same `path: ['__proto__']` shape as its landed sibling, deliberately — it adds no new exposure class, and changing the path shape for one of the two would create two dialects and pre-empt a decision that belongs to whoever takes that question. objectstack-ai#19151's body already records this connection; it is not this change's subject and not its acceptance condition. 3. **Two open PRs also hold `packages/spec/dropped-refinements.baseline.json`** — objectstack-ai#19373 and objectstack-ai#19335. That file is deliberately **not** `merge=os-regen` (recomputing a shrink-only ratchet can widen it), so whichever lands second reads the conflict by hand. No open PR holds either source file this change edits; lit control on the same scan, 18:25:35Z: 11 open PRs touch `packages/spec/` at all and 1 touches `packages/spec/src/automation/`. Not filed here: the PM files what is worth filing, per ruling A-narrow's own instruction that a further site is its own card **when measured**. ## Scope One site. ⛔ No sweep over the 398 records, ⛔ no re-opening of objectstack-ai#17852's ruling, and objectstack-ai#19147's `assignments` guard is untouched — it is correct and it is not this change's. objectstack-ai#18670, the JSON-Schema projection gap, is not addressed here and stays open. --- _Generated by [Claude Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ess-wrapped collection key cannot silently leave the merge refusal set (objectstack-ai#19150) (objectstack-ai#19314) Fixes objectstack-ai#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 (objectstack-ai#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 objectstack-ai#19147's BRANCH, which is still open and draft. ⛔ Untouched here on purpose — that file is objectstack-ai#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); ``` - **Why not `in || out`** (the shape objectstack-ai#19147 applied test-side): for a genuine `a.transform(fn).pipe(b)` the author writes `a`. `z.string().transform((s) => s.split(',')).pipe(z.array(z.string()))` is a key whose AUTHORED value is a scalar and whose parsed value is an array; `in || out` puts it in a refusal set that then tells the author their scalar is a collection whose entries would be dropped. Pinned as a dark-control assertion, not argued in prose: `eitherSideWalk` answers `true` for that shape, the landed rule answers `false`, and `composeStacks` composes it by later-wins. - **Why not "refuse to walk a transform"**: this walk runs inside `composeStacks` at author time; the derivation's job is to answer a structural question about every key, and a throw on a shape that is legal today would convert a silent gap into an outage. - **Why this one**: it is already the rule at four sibling sites (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts` since objectstack-ai#5317, `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` since objectstack-ai#5074, `packages/lint`'s `validate-predicate-path-refs.ts`), each carrying the objectstack-ai#4488 citation. Adopting it makes this a fifth SITE of one rule rather than a fifth dialect. The unwrap before the transform test is load-bearing and is pinned: a transform one level down is still a transform. ## 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 objectstack-ai#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 (bdb4aa8)` 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 objectstack-ai#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: 7d67e1e…`, **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: - `packages/lint/src/component-field-specs-liveness.test.ts:68` — `shapeOf` reads `def.in` only. A preprocess-wrapped `ComponentPropsMap` schema would make it record `"props schema has no resolvable object shape"` — a LOUD red, not a silent pass. Carrier: none today; no such schema exists. - `packages/spec/src/ui/component.test.ts:2907` — `def.in._zod.def.shape` on `PageComponentSchema` (`.strict().transform(…)`). Same shape, same loud failure (a TypeError on the next line). Carrier: none today. - **One divergence with a named carrier:** when objectstack-ai#19147 lands, the sibling test's independent walk will read `in || out` while the production walk reads the authorable side. Measured on today's shape the two agree, and the invariance block above asserts it — but they are two rules answering one question, which is the drift the derivation exists to avoid. The one-line alignment belongs to whoever lands objectstack-ai#19147, since that file is its surface today. 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](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…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>
…ces, and make the ratchet able to see it (objectstack-ai#19335) ⛔ **PARKED — 本 head 落不了地,且挡住它的不是本 PR。** 卡 objectstack-ai#18670 已转 `pm:blocked`,门禁卡是 **objectstack-ai#19240**(认领读者 `claimRetractions` 只认**同一 login** 的 `Release:`,`SKILL.md` :496 的死认领回收写不进它)。本 PR 的落地前置 ① 与 ③ 成立(达档 `## Contract review` 记录 `5749728565` 在 head `1dfe2f40bc` 上;checks 全绿);② 不成立:`check-clause2-carriers.mjs --pair 19335` = **exit 4**,唯一 ✗ 行是 C9(本卡线程上两条他席认领仍 LIVE)。完整读数、对照与本席自纠见 objectstack-ai#18670 评论 `5752999363`。⛔ 保持 draft,⛔ 不挂 auto-merge。 Part of objectstack-ai#18670 — item 2, the **fifth** arm the batch objectstack-ai#193 ruling added to the closed projection list, plus that ruling's **second acceptance item**. This body carries no closing keyword for that number on purpose: 566 dropped refinement sites remain across 205 published schemas, and whether the card closes is the seat's call rather than this PR's. Clause-②: yes **Carrier:** the published artefact `packages/spec/json-schema/data/NormalizedFilter.json`. The published JSON Schema **narrows** toward what the runtime already refuses, and no document the runtime accepts becomes refused. Director ruling `5749025303`, batch objectstack-ai#193 item 3, letter **A**, maintainer 「其他同意」 2026-09-20T09:44Z: 「A **fifth arm** joins the closed projection list: `propertyNames: { not: { pattern } }`, scoped to that one site and to the `^\$` ban, under the same one-ledger-row-at-a-time discipline as the four landed arms; the published keyword and the enforced predicate are built from a **single source** so they cannot name different things; an ablation proves the pin (the emitter removed ⇒ the rows return).」 Base `f93beea0a6`; head after merging `origin/main` (`e3b3cdd2df`) through `scripts/pm/os-regen-merge.sh`: **`1dfe2f40bc`**. --- ## 1. The measurement that decided step 1 — and it came out YES The ruling put one measurement **before** the arm: can those three `NormalizedFilter.json` nodes hold a ledger row at all? They read `undecidable`, and the thread's worry was that closing the rule would buy a narrower file with **no testable row** — the opposite trade from every arm landed so far. ⛔ It is not a grep question, and the card's own instruction says so: `packages/spec/json-schema/**` is **0 tracked files** on `origin/main` (lit control, same instrument: `packages/spec/src/data/` reads **167 tracked**), because `.gitignore:63` ignores it. Every reading below is against a tree **generated by the repo's own tooling** — `pnpm --filter @objectstack/spec build`, whose first step is `gen:schema` (`OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts`). **The answer: a row CAN be held, and the reason it was not is a defect in the detector.** The generator publishes `NormalizedFilter` through its **THIRD** projection attempt — `projectByPruningUnionBranches`, which drops the `z.date()` union branches and publishes the rest. The detector's `projectOrNull` stopped at the two strict rungs. So it was asking what a projection **nobody publishes** says, and answering `undecidable`: | node | plain output rung | plain input rung | branch-pruning rung | differential under it | |:---|:---|:---|:---|:---| | `lazy.$and.element.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | | `lazy.$or.element.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | | `lazy.$not.options[0]` | throws | throws | ok, 16772 bytes | **identical ⇒ `dropped`** | ⇒ the ruling's **first** branch applies: the detector **judges** those three nodes. The `undecidable` row shape was its fallback 「if a row cannot be held」, and that antecedent is false, so ⛔ no unread ledger field was added for an empty population. What the hole got instead is §2. ## 2. Second acceptance item — the blind spot, measured to zero and then pinned there `projectOrNull` now carries the generator's third rung and reports **which rung answered**, so a differential can never compare a pruned projection with an unpruned one (nothing observed reaches that guard; it is written down so the day it stops holding reads `undecidable` and is counted, rather than reading `projected` and vanishing). Repo-wide effect, from the generator's own census line: | | published schemas | dropped sites | projected | **undecidable** | |:---|---:|---:|---:|---:| | base `f93beea0a6` | 204 | 560 | 357 | **9** | | + the ladder rung | 205 | 569 | 357 | **0** | | + the arm (this PR) | 205 | 566 | 360 | **0** |⚠️ **The ledger GREW before it shrank, and the growth is the whole point of the item.** Seven sites became countable that no ratchet could see — `data/FieldOperators` and `data/NormalizedFilter` each gained their `$between` pair, and `data/RangeOperator` entered the ledger at all, a **published** schema that had been holding **zero** entries. Then the arm deleted three. Net: 204 entries / 560 sites → **205 / 566**. And a published site that still cannot be adjudicated now **fails the build by name**, printing the paths and the two legitimate remedies (teach the ladder a rung the generator has; or take the decision to give the ledger an `undecidable` row shape). ⛔ The hole cannot reopen in silence. ## 3. The arm, and the single source `banned-key-pattern` — 「no document may carry a key matching this pattern」 — emitted as `propertyNames` with a `not` over a `pattern`. A `$`-prefix ban is an **open** key set, so the existing `banned-keys` arm cannot express it: a finite list that merely sampled the set would be wider than the rule, which the closed list forbids by construction. **Single source, asserted rather than argued.** `bannedKeyPattern` compiles its regular expression **from** the declared pattern string, so the keyword the file publishes and the rule the runtime enforces are one string read twice. A test reads the emitted `pattern` off the published artefact and the declaration off the predicate and compares them — an emitter that re-spelled the rule, or a declaration edited without its predicate, fails there rather than drifting. **Exact, not approximate.** A JSON object's properties are exactly its own enumerable string-keyed ones, and `propertyNames` judges exactly those names. JSON Schema specifies `pattern` as an ECMA-262 regular expression evaluated as a SEARCH — unanchored, "does a match occur anywhere" — which is `RegExp.prototype.test` and nothing else. So `^\$` and the hand-written `key.startsWith('$')` it replaces name one set, pinned over a key corpus. It is presence and never value: a matching key present with a `null` value is present to both. **Scoped mechanically, which is how the ③ objection is answered.** The standing objection to a regex-shaped arm is that its over-reach cannot be read off the declaration the way a key list's can. The bound is a **second closed list**: `BannedKeyPattern` is a union of the pattern strings this package publishes, exactly one today, so a call site cannot invent a regex — there is no plain string type to pass, and widening it is the same reviewed decision that adding an arm is. The compiler refuses the second pattern; it does not arrive by a call site's choice. ⛔ No flags on the regular expression, and that is part of the equality rather than a style choice: a JSON Schema `pattern` has none to carry, and the global flag would make `test` stateful through `lastIndex`, so a key's verdict would depend on which keys were judged before it. Pinned both ways. ⛔ The predicate reads OWN enumerable keys and never the `in` operator — pinned with a name planted on the prototype, where the two readings actually come apart. ## 4. The card's own class, before and after — measured with a real validator ajv 8 (draft 2020-12) compiled against the **generated** `data/NormalizedFilter.json` on each side: | document | ajv BEFORE | ajv AFTER | |:---|:---|:---| | `{}` | true | true | | `{"$and":[{"amount":{"$eq":1}}]}` | true | true | | `{"$and":[]}` | true | true | | `{"$and":[{"$and":[]}]}` | true | true | | `{"$or":[{}]}` | true | true | | `{"$not":{}}` | true | true | | `{"$not":{"amount":{"$eq":1}}}` | true | true | | `{"$and":[{"$bogus":{"$eq":1}}]}` | **true** | **false** | | `{"$or":[{"$bogus":{"$eq":1}}]}` | **true** | **false** | | `{"$not":{"$bogus":{"$eq":1}}}` | **true** | **false** | The three that move are refused by the runtime, which names the rule: 「a field condition's keys are field names, never `$`-prefixed operators」. ⇒ the validator stops answering PASS on metadata the platform refuses, and **nothing the runtime accepts became refused** — the empty combinators and the nested group members are the direction that would have broken had the ban landed on the union instead of on the field-condition branch, and they are pinned. All three published nodes now carry the rule, conjoined and never substituted (a record states `propertyNames: { type: 'string' }` of its own, and replacing it would trade a key-TYPE rule for a key-NAME rule — a narrowing bought with a widening): ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "...": "the operator map" }, "allOf": [ { "propertyNames": { "not": { "pattern": "^\\$" } } } ] } ``` ## 5. Blast radius — the whole published tree The six source files were reverted to the base, the generator re-run, and the two trees compared byte for byte. **Revert leg proven on disk:** each path's blob hash equalled its base blob before anything ran. **Restore leg proven by bytes:** `git diff HEAD` printed **0 bytes**, `git status --porcelain` printed nothing, and each path's blob hash equalled its HEAD blob. | reading | value | |:---|:---| | files common to both trees | 1535 | | **byte-identical** | **1530** | | moved | **5** | The five, by name: `data/NormalizedFilter.json` (gains the ban at three nodes; gains the two `$between` annotation rows the ladder made visible), `data/FieldOperators.json` and `data/RangeOperator.json` (**annotation only** — they gain `x-dropped-refinements` rows, and `x-` keywords are ignored by every validator, so the set of documents they accept is unchanged), `objectstack.json` (the bundle; its 29 differing leaf paths sit under exactly those three definitions and nowhere else), and `.build-input-hash-schema`. ⭐ **`openapi.json` measured separately and with the right instrument.** `gen:schema` never writes it, so comparing it inside the sweep above would have read two copies of the same stale file and reported a false identical. `gen:openapi` was run on both trees: sha256 `34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa` on **both** sides — this arm reaches no schema that surface publishes. ## 6. Ablation — the pin can fail, and the rows do return `scripts/ablation-replace.mjs` replaced the one line dispatching the arm, with the mutation verified against the disk: anchor **1 → 0**, marker **0 → 1**, blob `4c5881bf5d1f` → `92da85bc6406`. ⭐ Resolution stated, because a false green here points the wrong way: every consumer reaches this module by a **relative** specifier, which resolves to source and never through the package `exports` to `dist`. There is no built artefact between the mutation and the verdict, so no dist preflight applies. | leg | result | |:---|:---| | `refinement-projection.test.ts` | **exit 1** — 12 failed / 66 passed, the single-source pin and the live seam among them | | `gen:schema` | **exit 1** — naming all three rows returning by name: `lazy.$and.element.options[0]`, `lazy.$not.options[0]`, `lazy.$or.element.options[0]` | | **restore** | blob back to `4c5881bf5d1f` **==** HEAD, `git diff HEAD` **0 bytes**, anchor back to 1 and marker back to 0 | The second leg is the ruling's own requirement: 「the emitter removed ⇒ the rows return」. They do — and they exist to return **only because** §2 made those nodes countable first. Regenerated afterwards, `data/NormalizedFilter.json` came back to sha256 `80041a0b…`, byte-identical to the pre-ablation artefact. ## 7. Verification — real exit codes, each captured before any pipe | check | exit | |:---|:---| | `pnpm --filter @objectstack/spec build` | **0** | | `pnpm --filter @objectstack/spec typecheck` | **0** | | `pnpm --filter @objectstack/spec test` | **0** — 500 test files / 14663 tests, all passed, dist built | | `pnpm --filter @objectstack/spec gen:schema` | **0** — ledger balanced | | `pnpm --filter @objectstack/spec gen:openapi` | **0** — byte-identical to base | | `pnpm --filter @objectstack/spec check:generated` | **0** — 16 of 16 generated artefacts up to date | | `pnpm lint` | **0** — the whole repository, `eslint . --no-inline-config`, not a narrowed subset | | derived gate families, reconciled by `scripts/pm/dispatch-gates.mjs --ran` | **86 derived / 82 exit 0 / 4 NOT MEASURED / 0 UNRUN** | The four NOT MEASURED each exit **3** — `PREREQUISITE NOT MET`, a code that is explicitly neither pass nor failure — because each needs a whole-repo build closure that CI produces: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`. ⛔ Declared, not skipped. ⭐ **`api-surface-declarations/` moved, and the movement is order-only — but it IS mine.** `check:api-surface` (the name-level gate) stays green with no diff at all. The declaration-text artefact did move, and rather than assume, it was tested: with this branch's six source files reverted to the base and the package rebuilt, `check:api-surface-declarations` exits **0** — so the movement belongs here. Characterised by bytes: 10 changed lines, 9 of them a whole-line multiset identity (two enum members swapping places), and the tenth a union whose quoted tokens are the same set, the same count, and whose text is identical once the tokens are masked. ⇒ **no declaration added, removed, or changed in meaning.** Regenerated and committed as its own commit. ## 8. Merge hygiene `origin/main` was merged in through `scripts/pm/os-regen-merge.sh` — ⛔ never rebased, ⛔ never force-pushed. That path was taken because `git check-attr merge` reads **`os-regen`** on `packages/spec/api-surface-declarations/api.txt` and `system.txt`, per file rather than by counting `.gitattributes` rows. After the merge the implementation body was re-asserted by name (`bannedKeyPattern`, `OPERATOR_PREFIX_KEY_PATTERN`, `BannedKeyPattern`, `emitBannedKeyPattern`, `conjoinPropertyNames`, `undecidableEntries`), the whole chain was regenerated, and `check:generated` reported 16 of 16 current with **no** regeneration diff. ## Acceptance notes -⚠️ **A dispatch instruction that the repository contradicts, named rather than quietly resolved.** The dispatch said to regenerate `packages/spec/dropped-refinements.baseline.json` 「with the repo's tooling; never hand-edit it」. There is no such tooling: the ledger has no `gen:` script by design, `build-schemas.ts` calls it 「a committed, hand-edited ledger」 in its own refusal text, and the module docblock argues the point at length — a generator would let a new gap be admitted by running a command instead of by a decision. The operative half of the ruling — 「⛔ do not serialise on it」 — was followed: this PR did not wait on objectstack-ai#19147. Every ledger edit here is the **corrected entry the gate itself printed**, pasted verbatim, which is the closest thing to tooling the artefact has. - **Noted, not filed — the sibling changeset in this same release now contradicts the tree.** `.changeset/18670-project-banned-keys.md` records that the `$`-prefix sites 「stay unprojected … carry NO annotation and hold NO ledger row: published yet unratcheted」. True of its own tree, false of this one. ⛔ Not rewritten — a landed record of what that PR shipped — so this PR's changeset states the supersession instead, and the two read coherently as one CHANGELOG. Carrier: none needed; both entries publish together. - **Noted, not filed — and this PR IS the carrier the previous one named.** objectstack-ai#19137 named 「the next PR that edits `packages/spec/scripts/build-schemas.ts`」 as carrier for a stale mention of the retired `api-surface-signatures.json`. This PR does edit that file, so it inherits the hand-off, and it is being declined deliberately: the line is a documentation nit in a comment, not one of the three filing classes, and it is not this ruling's defect class. It survives at `packages/spec/scripts/build-schemas.ts:874`. Carrier: the next PR that edits that file for a reason of its own. - **`dropped-refinements.baseline.json` is a shared hot file** held by objectstack-ai#19147. Not serialised on, per the ruling; collisions resolve by regenerating through `scripts/pm/os-regen-merge.sh`, ⛔ never by hand-editing conflict markers. - The arm list's own roster pin and the new pattern-set pin are both asserted as exact equalities, so a sixth arm — or a second pattern — updates a reviewed line in a diff rather than widening the narrowing quietly. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17852
Fixes #18847
What
Implements maintainer ruling A, narrow (comment 5725370319, batch #154 item 1) verbatim.
$ZodRecord's open-key branch (zod v4 core) runsif (key === "__proto__") continue;abovedef.keyType._zod.run, so no key schema — regex,.refine(),.superRefine(), or one that rejects every string — can ever see a__proto__key.ObjectSchema.fieldsused to accept a document whosefieldscarried a__proto__own key and hand back a document without it: success, silent, irreversible into whateveros buildwrites.Two mechanisms, one per name class, at the two sites the ruling names:
packages/spec/src/data/object.zod.ts:1964(ObjectSchema.fields) — wrapped in a new pre-parse guard (refuseRecordProtoKey,packages/spec/src/shared/record-proto-key-guard.ts) that reads the raw input's own keys viaz.preprocessand refuses a__proto__key with a named, located issue (fields.__proto__) before the record ever parses.constructorandprototype— which do reach the key schema unskipped (today's regex admits them as ordinary lowercase words) — are refused by the key grammar itself, via a.refine()beside the existing snake_case regex.packages/spec/src/automation/builtin-node-config.zod.ts:923(AssignmentConfigSchema.assignments) — the same pre-parse guard,__proto__only. This slot's key type (z.string().min(1)) carries no grammar;constructorandprototypeare legal flow-variable names today and are left legal — no ruling narrows this slot's accept set for those two names.packages/spec/src/stack.zod.ts:3027-3029— corrected the false// Post-parse and advisory: the stack is valid and is returned unchanged.comment. It was false twice over: the parse could drop a__proto__key, and:3032returnsmergeActionsIntoObjects(data), notdata. Region-disjoint from draft PR docs(spec): scope the email-template locale-floor claims to a call that names a locale #18482 (its hunks are old lines 2853-2924), confirmed against the real PR file diff before editing; nothing else in this file was touched.A side effect the wrapping caused, and its fix
z.preprocess'sinhalf is aZodTransform, which unconditionally hardcodes_zod.optin = "optional"— a preprocess accepts any input, includingundefined, regardless of what the wrapped schema does. Left alone, that madeObjectSchema.fields(which carries no.optional()) report as optional to$ZodObject's own JSON-Schema requiredness check (objectProcessor,io === 'input'), so the publisheddata/Objectschema silently droppedfieldsfrom itsrequiredarray while the runtime parse still correctly refused a missingfields.refuseRecordProtoKeynow patchesoptin/optouton the pipe's innerdef.in(not the outer pipe, which every.describe()/.optional()a caller chains afterward clones away) to mirror the wrapped schema's own values — verified before/after withz.toJSONSchema(ObjectSchema, { io: 'input' }). See the docblock inrecord-proto-key-guard.tsfor the full mechanism.Two things flagged by the dispatching seat, answered directly
compose-stacks-merge-collection-refusal.test.ts— this is a direct, mechanical consequence of the guard, not a defect found next door, and it stays in this PR. The test's own independentisCollectionwalker structurally pattern-matchesObjectSchema.shape.fields's zod type; before this changefieldswas a bareZodRecord, and wrapping it inz.preprocessnecessarily makes it aZodPipe. The walker'spipecase only recursed intodef.in(correct for a.pipe()combo, whereinis the original type) and missed the record hidden indef.out(the conventionz.preprocess(fn, schema)actually uses). Fixed to check both sides of a pipe. The production merge/refuse logic instack.zod.ts(declaresCollection/objectCollectionKeys) has the identicaldef.in-only blind spot, but it is functionally unaffected here becausefieldsis excluded from that logic by literal key name, beforedeclaresCollectionis ever consulted — confirmed with an end-to-endcomposeStacks({ objectConflict: 'merge' })probe that still shallow-mergesfieldscorrectly. That production blind spot is a real, separate, dormant defect for any future collection-typed key that gets wrapped inz.preprocess(notfields— that one is safe by name) and is reported below as an out-of-scope finding rather than fixed here, sincestack.zod.tsoutside the 3027-3029 region is explicitly fenced off this card.Regenerated spec artifacts — three, all produced by the repo's own generators, none hand-edited:
content/docs/references/{api/metadata,data/object,system/migration}.mdx— viapnpm --filter @objectstack/spec gen:docs, reflecting the new.describe()text onObjectSchema.fields(and, before theoptin/optoutfix above, briefly and incorrectly downgradedfieldsto "optional" — caught and fixed before this diff, confirmed by the requiredness fix and a full rebuild).packages/spec/dropped-refinements.baseline.json— hand-edited, not generated (it has nogen:script by design;check:generated's underlyingbuild-schemas.tsprints the exact correctedsitesarrays on a mismatch, and this edit pastes those verbatim, extracted programmatically from the build's own output rather than transcribed by hand). Nine entries gained afields.out.keyType/assignments.out.valueType-shaped site: the new.refine()onObjectSchema.fields' key type, and the.outpath segment thez.preprocesswrapper's pipe structure introduces, neither of which projects into the published JSON Schema (see "Known gap" below) —measured.droppedRefinementSitesmoved from 553 to 562 accordingly.Known gap (stated by the ruling, not closed here)
The guard does not project into the published JSON Schema (
packages/spec/json-schema/**) — that general gap is #18670 and this card does not wait on it.Tests
packages/spec/src/shared/record-proto-key-guard.test.ts(new) — pins the guard in isolation against a minimal record: refuses__proto__with a named, located issue; a control proves the underlying unguarded record really would have silently dropped it; leaves ordinary keys, non-object input,.optional()composition and a caller's own{ error }option untouched.packages/spec/src/data/object.test.ts— pinsObjectSchema.fieldsrefusing__proto__(named issue, never falls through to the key-grammar's regex message), refusingconstructor/prototypevia the key grammar (invalid_key, nested refine message), and still accepting an ordinary document.packages/spec/src/automation/builtin-node-config.test.ts— pinsAssignmentConfigSchema.assignmentsrefusing__proto__, and a preservation pin thatconstructor/prototyperemain accepted as flow-variable names.packages/spec/src/compose-stacks-merge-collection-refusal.test.ts— updated per the scope note above; all 62 cases pass.Every pin is a behaviour pin against the pinned
zod@^4.4.3, not a version-string pin, per the dispatch's instruction.Gates run on this PR's head
pnpm --filter @objectstack/spec build— clean.pnpm --filter @objectstack/spec check:generated— all 16 generated artifacts up to date, includingcheck:api-surface✓ andcheck:authorable-surface✓ (both named by the ruling).pnpm --filter @objectstack/spec test— 498 files / 14569 tests, all pass.pnpm --filter @objectstack/spec typecheck— clean (tsc --noEmit,check:scripts-typecheck,check:test-typecheck; the pre-existing 259-error/144-signature test-typecheck debt ledger is unchanged).node scripts/check-adr-0087-registration.mjs --base origin/main— the changeset'snot-required (no-migration-prescription)disposition verified against the census (zero authored use anywhere reached).node scripts/pm/dispatch-gates.mjs --commandsderivation for this diff: 102 families derived, 99 run and green, 3 correctly NOT-MEASURED (check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— each refuses onPREREQUISITE NOT MET/exit 3, requiring a full ~80-package workspace build outside this card's local scope; not a finding).dist/, not onlysrc/(importeddist/data/index.mjsdirectly and re-probed).origin/mainmid-flight (an unrelatedspecPR landed); rebuilt, re-rancheck:generated, the full test suite and typecheck again on the merged tree — all clean.Out-of-scope findings (not filed, not fixed here)
stack.zod.ts'sdeclaresCollection(case 'pipe': return declaresCollection(def.in, ...)) only reads theinside of a pipe. Forz.preprocess(fn, schema)the real type sits inout, so a future collection-typed key onObjectSchema.shapewrapped inz.preprocesswould silently stop being refused byobjectConflict: 'merge''s collision guard (composeStacks objectConflict: 'merge' merges fields only — the later object's actions (and every other key) replace the earlier package's wholesale, silently dropping its embedded actions #14848's own shape). Harmless forfieldstoday only because it is excluded by literal key name first. Dedupe words:declaresCollection,objectCollectionKeys,z.preprocess,pipe def.in,objectConflict merge.AssignmentConfigSchema's own.catchall(z.unknown())drops a top-level__proto__variable the same way) was re-measured:$ZodObject's catchall branch (handleCatchall, zod v4 core) carries the identicalif (key === "__proto__") continue;skip, with its own comment ("skip__proto__so it can't replace the result prototype via the assignment setter"). So the lead holds — a variable literally named__proto__at the top level of an assignment node config is silently dropped by the catchall the same way. Per the dispatch's instruction this is reported, not fixed, and not widened into this PR. Carrier: whoever files it — dedupe wordsAssignmentConfigSchema catchall,handleCatchall __proto__,top-level assignment variable.Clause-②: yes (narrowing)
Generated by Claude Code