Skip to content

spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) - #19147

Merged
os-elon-musk merged 8 commits into
mainfrom
claude/issue-17852-record-key-preparse-guard
Sep 20, 2026
Merged

os-elon-musk merged 8 commits into
mainfrom
claude/issue-17852-record-key-preparse-guard

Conversation

@os-elon-musk

Copy link
Copy Markdown
Collaborator

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) runs if (key === "__proto__") continue; above def.keyType._zod.run, so no key schema — regex, .refine(), .superRefine(), or one that rejects every string — can ever see a __proto__ key. ObjectSchema.fields used to accept a document whose fields carried a __proto__ own key and hand back a document without it: success, silent, irreversible into whatever os build writes.

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 via z.preprocess and refuses a __proto__ key with a named, located issue (fields.__proto__) before the record ever parses. constructor and prototype — 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; constructor and prototype are 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 :3032 returns mergeActionsIntoObjects(data), not data. 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's in half is a ZodTransform, which unconditionally hardcodes _zod.optin = "optional" — a preprocess accepts any input, including undefined, regardless of what the wrapped schema does. Left alone, that made ObjectSchema.fields (which carries no .optional()) report as optional to $ZodObject's own JSON-Schema requiredness check (objectProcessor, io === 'input'), so the published data/Object schema silently dropped fields from its required array while the runtime parse still correctly refused a missing fields. refuseRecordProtoKey now patches optin/optout on the pipe's inner def.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 with z.toJSONSchema(ObjectSchema, { io: 'input' }). See the docblock in record-proto-key-guard.ts for 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 independent isCollection walker structurally pattern-matches ObjectSchema.shape.fields's zod type; before this change fields was a bare ZodRecord, and wrapping it in z.preprocess necessarily makes it a ZodPipe. The walker's pipe case only recursed into def.in (correct for a .pipe() combo, where in is the original type) and missed the record hidden in def.out (the convention z.preprocess(fn, schema) actually uses). Fixed to check both sides of a pipe. The production merge/refuse logic in stack.zod.ts (declaresCollection/objectCollectionKeys) has the identical def.in-only blind spot, but it is functionally unaffected here because fields is excluded from that logic by literal key name, before declaresCollection is ever consulted — confirmed with an end-to-end composeStacks({ objectConflict: 'merge' }) probe that still shallow-merges fields correctly. That production blind spot is a real, separate, dormant defect for any future collection-typed key that gets wrapped in z.preprocess (not fields — that one is safe by name) and is reported below as an out-of-scope finding rather than fixed here, since stack.zod.ts outside 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 — via pnpm --filter @objectstack/spec gen:docs, reflecting the new .describe() text on ObjectSchema.fields (and, before the optin/optout fix above, briefly and incorrectly downgraded fields to "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 no gen: script by design; check:generated's underlying build-schemas.ts prints the exact corrected sites arrays 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 a fields.out.keyType / assignments.out.valueType-shaped site: the new .refine() on ObjectSchema.fields' key type, and the .out path segment the z.preprocess wrapper's pipe structure introduces, neither of which projects into the published JSON Schema (see "Known gap" below) — measured.droppedRefinementSites moved 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 — pins ObjectSchema.fields refusing __proto__ (named issue, never falls through to the key-grammar's regex message), refusing constructor/prototype via the key grammar (invalid_key, nested refine message), and still accepting an ordinary document.
  • packages/spec/src/automation/builtin-node-config.test.ts — pins AssignmentConfigSchema.assignments refusing __proto__, and a preservation pin that constructor/prototype remain 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, including check:api-surface ✓ and check: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's not-required (no-migration-prescription) disposition verified against the census (zero authored use anywhere reached).
  • node scripts/pm/dispatch-gates.mjs --commands derivation 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 on PREREQUISITE NOT MET/exit 3, requiring a full ~80-package workspace build outside this card's local scope; not a finding).
  • Confirmed the fix reaches the rebuilt dist/, not only src/ (imported dist/data/index.mjs directly and re-probed).
  • Rebased onto origin/main mid-flight (an unrelated spec PR landed); rebuilt, re-ran check:generated, the full test suite and typecheck again on the merged tree — all clean.

Out-of-scope findings (not filed, not fixed here)

  • To file (class a, reproducible): stack.zod.ts's declaresCollection (case 'pipe': return declaresCollection(def.in, ...)) only reads the in side of a pipe. For z.preprocess(fn, schema) the real type sits in out, so a future collection-typed key on ObjectSchema.shape wrapped in z.preprocess would silently stop being refused by objectConflict: '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 for fields today only because it is excluded by literal key name first. Dedupe words: declaresCollection, objectCollectionKeys, z.preprocess, pipe def.in, objectConflict merge.
  • Noted, not filed: the measurement lead in the dispatch (whether 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 identical if (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 words AssignmentConfigSchema catchall, handleCatchall __proto__, top-level assignment variable.

Clause-②: yes (narrowing)


Generated by Claude Code

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

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json adf4b18777d507236cd24b7ed59b45a7c71bd1fd.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 62 pages)
  • 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 adf4b18777d507236cd24b7ed59b45a7c71bd1fd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1782a33e51cb0df868673e04b39cd41b823d82a1 — the merge of head 4cdba204156b06cef828319a8c75f284b49ad0cf into base adf4b18777d507236cd24b7ed59b45a7c71bd1fd, 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 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

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

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

Copy link
Copy Markdown
Collaborator Author

One check went red and is green again; the cause was the dispatching seat's own instruction, not this branch's code. Seat domain:spec#3, 2026-09-18T23:30Z.

check:closing-target-claim — 「The card this PR closes must claim this branch」 — failed on head 490fc0246 (run 35405274233). Its rule: a pull request may close a card only while that card's own thread carries a Claim: naming the PR's head branch. This PR carries two closing keywords, Fixes #17852 and Fixes #18847. #17852 was claimed and named this branch; #18847 carried no Claim: at all, so the gate refused — correctly.

⭐ 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, Thread-read: carrying the exact preceding comment id), and only then was that one job re-run. It now reads success (run status completed, conclusion success, read back by this seat).

⛔ What was NOT done, and will not be: the gate was not weakened, no check was skipped or quarantined, the Fixes #18847 line was not quietly dropped, and no empty commit was pushed to kick CI. The re-run was not a blind retry either — the gate's INPUT changed between the two runs, which is the one case where re-running answers a different question than the first run did.

⚠️ For whoever reads this PR next: its implementing dev died without delivering a report (its container was restarted at about 23:20Z), so this PR has no os-dev-report, no check:api-surface / check:authorable-surface readings, and no author to ask. What this seat verified by hand is recorded on #17852 (posted 23:27Z) and #18847 (claim 5737404977); an isolated at-tier contract review is in flight, and needs:contract-review stays on this PR until it lands. ⛔ Absence of a report is not read as success here.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 85/85 CONTRACT_REVIEW_TIER
Head-sha: 490fc02466262a4472a5642330bc465e22d533c0

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 origin/main...490fc0246 (merge-base ee5812a5e, 13 files, +464/−40), the tree, and zod 4.4.3's own source (the version packages/spec resolves; tarball read in a scratch dir). The shared checkout has no node_modules, so no gate or test was run here — where CI is cited, the reading time is given; nothing unrun is called green.

① Derived judgments

Declared Clause-②: yes (narrowing) — correct, and the narrowing is exactly the ruled one.

  • Mechanism verified in zod 4.4.3 source, not the docblock's quote. $ZodRecord's open-key branch (core/schemas.js) reads for (const key of Reflect.ownKeys(input)) { if (key === "__proto__") continue; if (!propertyIsEnumerable) continue; let keyResult = def.keyType._zod.run(...) — the skip sits above the key schema, so no regex or .refine() on the key can see __proto__. z.preprocess(fn, schema) is ZodPreprocess({ type: 'pipe', in: transform(fn), out: schema }), and handlePipeResult returns left with aborted = true the moment in carries any issue, so the record never runs on a __proto__-bearing input: nothing is dropped, nothing is repaired, the document is refused. That is A, not 乙.
  • (a) ObjectSchema.fields (data/object.zod.ts:1965-1984): both halves present — refuseRecordProtoKey(...) for __proto__, and a .refine() refusing constructor and prototype beside the existing /^[a-z_][a-z0-9_]*$/ (which admits both words). The changeset sentence is therefore true for all three names at this slot.
  • (b) AssignmentConfigSchema.assignments (automation/builtin-node-config.zod.ts:924-938): the guard ONLY; the key type stays z.string().min(1) with no refine; the in-code comment says why; builtin-node-config.test.ts carries a preservation pin that constructor / prototype still parse. Independent corroboration from the ledger: automation/AssignmentConfig's entry is a pure path rename (assignments.valueType → assignments.out.valueType, delta 0) with no keyType site — a refine on that key would have added one. No unauthorised narrowing.
  • (c) Refuses, named, located. The guard pushes one custom issue via the classic ZodTransform's payload.addIssue (code ??= 'custom', continue left unset), message `fields` cannot contain a key named "__proto__" … Rename the key., path ['__proto__'], which $ZodObject prefixes to ['fields', '__proto__'] / ['assignments', '__proto__']. object.test.ts pins that it never falls through to the regex message; record-proto-key-guard.test.ts carries the lit control (the unguarded record returns success: true with Reflect.ownKeys(data) equal to ['a']).
  • Guard read line by line (shared/record-proto-key-guard.ts, 105 lines). Prototype-chain __proto__ (an object-literal { __proto__: … }) is not an own key, zod never iterates inherited keys, nothing is dropped, the guard correctly stays silent — not a bypass, and it matches the ruling's 「reads the input's own keys」. Null-prototype objects: Reflect.ownKeys and Object.prototype.propertyIsEnumerable.call both work without a prototype. Non-object, null, array and non-plain-object input pass through to the record's own invalid_type / isPlainObject gate and its own { error } option (pinned: the assignments array-form prescription still fires). A non-enumerable own __proto__ is skipped by the guard and by zod alike for every key name, so no __proto__-specific silence is added. Symbol keys are unaffected by a strict string compare. For every input without the key, the record runs exactly as before — the record's error contract is intact; the one ordering consequence is that a __proto__-bearing document reports only the guard's issue in that pass (the pipe aborts before the record's other key/value issues), acceptable for a refusal at the door.
  • z.input / z.infer unchanged; the runtime node kind is not. The as unknown as Schema cast keeps both slots' published TS types (the record's), so api-surface/** and api-surface-signatures.json are rightly untouched. At runtime ObjectSchema.shape.fields._zod.def.type is now 'pipe', not 'record'. Measured readers: zero non-test reads of ObjectSchema.shape.fields in objectstack or objectui (the four hits are tests on other schemas); stack.zod.ts:3385 skips fields by literal name before declaresCollection is asked. z.toJSONSchema is unaffected because zod's pipeProcessor reads def.out for io: 'input' when in is a transform.
  • The optin patch is real and its placement is right. $ZodTransform.init does inst._zod.optin = "optional" as a plain assignment ($ZodType.init never defineLazys optin, so it is writable); $ZodPipe.init derives optin lazily from def.in._zod.optin; objectProcessor puts a key in required only when optin === undefined. Without the patch the published data/Object would have lost fields from required while the runtime still refused a missing fields; patching the transform (carried by reference through .describe()'s clone) is the placement that survives. The regenerated docs still render fields as required (✅) in all four table rows.
  • stack.zod.ts is touched in one hunk, lines 3027-3035 only; the corrected comment is true (the call is advisory, mergeActionsIntoObjects(data) is what returns, and other records in the stack can still drop a key — the narrow reading leaves them).

② Semver level

@objectstack/spec: minor with a BREAKING banner, Clause-②: yes (narrowing) — correct. AGENTS.md (Post-Task Checklist §3): yes takes at least minor, (narrowing) is BREAKING; the ruling itself says 「changeset @objectstack/spec minor」.

  • The sentence describes what shipped, asymmetry included. First line: ObjectSchema.fields refuses __proto__, constructor, prototype; AssignmentConfigSchema.assignments refuses __proto__ — and the body says why the two differ (no grammar on that key, both names measured legal, no ruling narrows it). It does not claim the guard reaches the published JSON Schema; it names [finding] the published JSON Schema is WIDER than the zod schema it is generated from wherever a .refine() carries the rule — an author validating against packages/spec/json-schema/** gets a green for metadata the runtime refuses #18670. This is the sentence 甲 could not have written.
  • The zero is tested, with lit controls. Pattern (['"]?)(__proto__|constructor|prototype)\2\s*: over *.ts,tsx,js,mjs,json,yaml,yml,md,mdx, excluding node_modules / dist / .git, no head -N: objectstack 45 hits (15 outside test/fixture paths — all prose comments, __proto__: null literal discussions, driver-turso CHANGELOG adr lines; 30 inside tests — test inputs and pins), objectui at d18322415 35 hits (33 in test files, one TS type member constructor: new (…) in packages/types/src/zod/node-derivation.ts:111, one prose comment). Authored fields keys or assignments variable names among them: 0. Controls on the same subject: the same pattern fired 45 / 35 times, and the field key first_name: returns 6 hits in objectstack examples/ and 20 in objectui. Scope of the zero: this repo, examples/, objectui — as the changeset states; cloud / hotcrm unmeasured, as in the ruling's own census.
  • adr-0087: not-required (no-migration-prescription) is a listed category (CATEGORIES, check-adr-0087-registration.mjs:489); nothing is removed or renamed, and the one-line fix (「Rename the key」) rides in the refusal message, so a FROM → TO in the body would contradict the disposition. CI Check Changeset (job changeset-check, which runs check-adr-0087-registration.mjs --base MERGE_BASE) read success at 23:29:49Z and 23:40:30Z.

③ Boundary flags

⭐ The ratchet line — advice for the maintainer's hand, not settled here. dropped-refinements.baseline.json: droppedRefinementSites 553 → 562; both numbers equal the sum of sites over the 202 entries on their respective heads; publishedSchemasWithDroppedRefinements stays 202 (no schema newly enters the population).

Other flags

  • compose-stacks-merge-collection-refusal.test.ts: the walker's pipe case reading def.in only would classify the new fields as a transform, not a collection — the in || out change is a direct consequence of the wrapper, not a fix riding along. The production twin stack.zod.ts:3353 (declaresCollection reads def.in only) is dormant for fields because :3385 excludes it by name first; it is the PR body's 「to file」 finding and is not yet filed (dead dev) — the seat should file it or hand it on.
  • The dispatch's measurement lead holds: handleCatchall in zod 4.4.3 carries the same if (key === "__proto__") continue; (core/schemas.js:767-769), so a top-level __proto__ variable on an assignment node config is dropped by the .catchall(z.unknown()) the same way. Reported in the PR body, not filed — same hand-over.
  • A new hazard, measured, not a verdict condition: this guard produces the first zod issue in this codebase whose path contains __proto__. zod 4.4.3's treeifyError (properties['__proto__'] ??= … reads Object.prototype, then .errors.push on it) and formatError / error.format() (curr['__proto__']._errors.push) both throw a TypeError on exactly this issue; flattenError, prettifyError, toDotPath and error.issues are fine. Consumers in reach: zero non-test calls to any of these formatters in objectstack (the same pattern lit three .format() calls in objectui, all on gantt/map/timeline config schemas, none on these two). Remedy if wanted before merge: path: [] in the guard (the message already names slot and key) with the three tests' fields.__proto__ / assignments.__proto__ path assertions moved to the slot; a re-review would be limited to record-proto-key-guard.ts and its three test files. Otherwise an own card.
  • Docs: the .describe() text has 4 rendered rows on origin/main (api/metadata.mdx, data/object.mdx, system/migration.mdx ×2) and all 4 are updated on the head; the text is true of ObjectSchema.fields and no page speaks for assignments. packages/spec/json-schema/** is gitignored, so its x-dropped-refinements and description changes correctly leave no diff.
  • CI on 490fc0246, read first-hand at 23:40:30Z: 39 check-runs — 33 success, 5 skipped, 1 in_progress (Lint & Repo Gates, started 23:20:08Z — the job that runs check:authorable-surface, check:api-surface and check:generated --reconcile-only, the two gates the ruling names), 0 failures. The earlier The card this PR closes must claim this branch failure reads success since the seat's claim on [finding] AssignmentConfigSchema.assignments is keyed by author-named flow VARIABLE names, so a variable named __proto__ is silently dropped from the parsed flow config — the #17852 shape, one slot over and fenced out of that round #18847. Build Core (pnpm build, which runs build-schemas.ts and therefore the ledger comparison and the guard module's optin assignment) read success at both 23:29:49Z and 23:40:30Z; Test Core 1-6 and Type Check · workspace read success at 23:40:30Z. ⛔ Lint & Repo Gates is not green until it completes; the ruling's 「blast radius measured on the PR head」 for the two surface gates is therefore still CI's to finish, not this review's to assert.

Implemented-by: claude/issue-17852-record-key-preparse-guard
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

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 minor + BREAKING + not-required (no-migration-prescription) is the right declaration with a zero that holds under a lit control. Two lines stay outside this verdict's reach and are named above for the hands that own them: the ratchet +9 (maintainer's floor; advice given) and Lint & Repo Gates finishing on this head.


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).

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 47/47 CONTRACT_REVIEW_TIER
Head-sha: 4cdba204156b06cef828319a8c75f284b49ad0cf

Re-review of record for the head that moved after comment 5737516015 (PASS on 490fc0246, 2026-09-18T23:43:53Z). Scope is the inter-head delta 490fc0246..4cdba2041; the earlier record's ①②③ stand for everything the delta does not touch and are not restated. Every reading below is mine unless marked as the dev's or the seat's; the shared checkout has no node_modules, so no gate or test was run here, and CI is cited with its reading time. Tier control: 47 of 47 assistant-message lines in this reviewer's own transcript (9 distinct requests), measured at composition, were served by claude-fable-5-1, the value CONTRACT_REVIEW_TIER holds on origin/main at adf4b1877.

① Derived judgments

The delta is main arriving plus one re-measured ledger; nothing PR-authored moved. The earlier PASS survives on this head.

  • Commits. 77 commits in 490fc0246..4cdba2041, 3 of them first-parent on the branch: fcef6de83 (merge of origin/main at eeaa88245), 2da35eb60 (merge of origin/main at adf4b1877), 4cdba2041 (ledger re-measurement plus the gen:docs deferral discharge). The other 74 are main's own. The merge-base with main is adf4b1877 itself: 0 behind, 8 ahead.
  • Interdiff of the PR-authored surface. The PR's patch against its base at each head — git diff ee5812a5e 490fc0246 versus git diff adf4b1877 4cdba2041 over the 12 non-ledger files, index lines stripped — differs in exactly ONE line: the stack.zod.ts hunk header (@@ -3024 became @@ -3087), i.e. main added 63 lines above the PR's comment hunk. The guard module and its test, both slots, object.test.ts, builtin-node-config.test.ts, the compose-stacks test, the changeset and the three .mdx rows are byte-identical between the two heads (blob ids compared file by file). What would have made this non-zero: a hand edit during either conflict resolution — there was none outside the ledger.
  • No silent drop by either merge. Main touched two of the 13 files in ee5812a5e..adf4b1877: stack.zod.ts (1 commit, 24d622b94, 79 changed lines in 8 hunks, all above line 1170, none naming declaresCollection, fields, def.in or mergeActionsIntoObjects) and data/object.mdx (1 commit, 1b82c519d, 2 lines). On the head, git diff adf4b1877 4cdba2041 for those two files is the PR's own delta only (9+/3− and 1+/1−), so main's lines are present under the PR's. object.zod.ts, builtin-node-config.zod.ts, the three tests, metadata.mdx and migration.mdx: 0 main commits in the window, so their unchanged blobs are what a clean merge produces, not a drop. The fields name-skip in stack.zod.ts sits at :3454 on the head, still ahead of the declaresCollection call at :3455. zod stays 4.4.3 (0 zod lines changed in pnpm-lock.yaml across the window), so the earlier record's mechanism reading is unchanged.
  • CI on 4cdba2041, read 2026-09-20T10:08:18Z: 36 check-runs — 15 success, 5 skipped, 16 in_progress, 0 failures. Green: Check Changeset (both runs, 2026-09-20T10:01:45Z and 2026-09-20T10:03:56Z — the job that runs check-adr-0087-registration.mjs, whose blob DID move on main in the window; the marker still clears on this head) and Governed Surface Queue Guard (2026-09-20T10:01:42Z). Still running: Lint & Repo Gates, Build Core, all six Test Core shards, the three Type Check jobs, the three Dogfood Regression Gate shards, Temporal Conformance. ⛔ in_progress is not green; the ruling's 「blast radius measured on the PR head」 is CI's to finish for this head exactly as it was for the last one. The dev reports (5749112643, ⛔ not re-run here) gen:schema twice, check:authorable-surface, typecheck and check:nul-bytes all exit 0 on this tree, and declares the full suite skipped this round.

② Semver level

Unchanged and still correct: @objectstack/spec: minor, BREAKING banner, Clause-②: yes (narrowing), adr-0087: not-required (no-migration-prescription). The changeset blob is identical on both heads; no-migration-prescription is a listed category on adf4b1877 (scripts/check-adr-0087-registration.mjs, 50 occurrences, the same count as at ee5812a5e); Check Changeset is green on this head.

The ledger, taken here per entry at all three anchors — dropped-refinements.baseline.json, header versus the sum of sites over entries:

anchor (main → head) header sum of sites entries +sites −sites net new fields.out.keyType renames
ee5812a5e → 490fc0246 553 → 562 553 → 562 202 → 202 29 20 +9 9 20
eeaa88245 → fcef6de83 551 → 560 551 → 560 200 → 200 29 20 +9 9 20
adf4b1877 → 4cdba2041 560 → 569 560 → 569 204 → 204 29 20 +9 9 20

Header equals sum on all six ledgers. The 9 new sites are the same 9 schemas at every anchor — api/AssembledInstalledPackage, api/GetInstalledPackageResponse, api/InstalledPackageAtEitherStage, api/ListInstalledPackagesResponse, api/ObjectDefinitionResponse, data/Object, system/ChangeSet, system/CreateObjectOperation, system/MigrationOperation — one …fields.out.keyType each. Every one of the 20 removed paths is matched by an added path with .out. inserted (19 under fields; 1 is automation/AssignmentConfig assignments.valueType → assignments.out.valueType, delta 0), and 0 paths are unmatched in either direction. The other three measured keys (204 / 357 / 9) are byte-identical to main's. The +9 is a stable attribution, not a coincidence: one site per embedding schema, unchanged while main's own baseline moved 553 → 551 → 560 under it. What would make it different: a .refine() on AssignmentConfigSchema.assignments' key type (there is none — its entry is delta 0, which is the earlier record's lit control), or main removing one of the 9 embedding schemas (it did not: no entry exists on only one side).

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 transform node with no checks and contributes 0 ledger sites (no fields.in.* path appears anywhere in the diff above). The +9 comes from the ruling's OTHER half — 「constructor / prototype are refused by the key grammar」 — implemented as a .refine() on the key type, which zod cannot project. Both halves are the same #18670 class, the ruling priced non-projection as a known cost and said this card does not wait on #18670, and the earlier record called the +9 「the honest ledger of the ruling as written」. That is still true on this head.

(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 5eebc9edc (PR #19137, landed 2026-09-19T00:50:01Z, 67 minutes after the earlier PASS) added the banned-keys arm to the closed projection list under the SAME batch #154, item 3 letter C: bannedKeys([...]) in packages/spec/src/shared/refinement-projection.ts is a .refine() predicate on the RECORD that publishes as propertyNames with a not over the names, reads own properties only, and whose docblock discusses constructor by name. Spelled z.record(keySchema, FieldSchema).refine(bannedKeys(['constructor', 'prototype']), ...) inside the existing refuseRecordProtoKey(...) wrapper, the same runtime refusal would read projected rather than dropped, and the expected ledger delta is +0, with 9 sites moving to refinementSitesThatDidProject — expected, ⛔ not measured here. Two things would make it different: the projection walker's handling of a refine that sits on a pipe's out edge, and the object.test.ts pins at :2706-2726, which assert the issue at path fields.constructor / fields.prototype with code invalid_key — a record-level refine reports at fields with code custom, so those pins move with it. One file plus a re-measure. This does not fail the PR: the ruling does not require it, the changeset makes no claim it contradicts (its 「known gap」 paragraph is about the guard), and #19137's own body says the remaining measured banned-key sites convert one ledger row at a time under #18670. It does mean the seat should put the +9 to the maintainer as 「the ruled cost, for which a +0 spelling landed on main after the ruling and after the first review」, not as 「the cost line 11 already priced」. The floor is the maintainer's; this record only corrects what he would be choosing between.

③ Boundary flags

Implemented-by: claude/issue-17852-record-key-preparse-guard
Reviewed-by: session_019srGWGCBBCBHqcDoRZpQRh

VERDICT: PASS

On this head: nothing PR-authored moved between 490fc0246 and 4cdba2041; the inter-head delta is main's 74 commits arriving and the ledger's four numbers re-measured; the +9 is the same nine fields.out.keyType sites at every anchor and is the ruling's key-grammar half, honestly ledgered. Outside this verdict's reach and named above for the hands that own them: the maintainer's floor on the +9, now with a +0 spelling available on main that was not available at the first review; and Lint & Repo Gates / Build Core / Test Core finishing on this head.


Generated by Claude Code

@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit b1d3945 Sep 20, 2026
43 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-17852-record-key-preparse-guard branch September 20, 2026 13:21
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment