Skip to content

Add comprehensive UI protocol examples in examples/ui - #154

Merged
hotlong merged 4 commits into
mainfrom
copilot/add-examples-to-ui-folder
Jan 25, 2026
Merged

hotlong merged 4 commits into
mainfrom
copilot/add-examples-to-ui-folder

Conversation

Copilot AI commented Jan 25, 2026 •

Copy link
Copy Markdown
Contributor

Creates a comprehensive set of UI Protocol examples organized into three separate sub-packages under examples/ui/:

Sub-Packages

1. metadata-examples/

TypeScript/JSON metadata configurations demonstrating UI structure definitions:

  • view.examples.ts - 17 examples: Grid, Kanban, Calendar, Gantt views with various data providers (object, API, static)
  • page.examples.ts - 9 page layouts: Record, Home, App, and Utility pages demonstrating component composition patterns
  • dashboard.examples.ts - 6 comprehensive dashboards: Sales, Service, Executive, Marketing, and Team productivity with widget compositions
  • action.examples.ts - 22 examples: Modal, Flow, Script, URL, and Batch actions covering common business operations
  • app.examples.ts - 7 applications: Simple to comprehensive apps with hierarchical navigation and permission-based access
  • theme.examples.ts - 7 themes: Light, dark, colorful, minimal, and WCAG AAA compliant variants

2. custom-components/

React component implementations showing how to build custom UI components:

  • CustomButton.tsx - Flexible button component with multiple variants, sizes, and theming support
  • CustomDataGrid.tsx - Advanced data grid with sorting, filtering, pagination, and row selection
  • Component registry system - Pattern for registering and retrieving components by type name

3. react-renderer/

Demonstrates how to render UI metadata using React:

  • PageRenderer - Renders complete Page metadata with region layouts
  • ComponentRenderer - Renders individual components with error boundaries
  • Template expression resolver - Handles data binding with {field.path} syntax
  • SimpleApp - Complete integration example showing metadata rendering

Organization

All three sub-packages have their own package.json, tsconfig.json, and comprehensive README documentation, creating a clean separation between:

  1. The "what" (metadata defining UI structure)
  2. The "implementation" (custom React components)
  3. The "how" (rendering engine)

Testing

  • ✅ All examples build successfully with TypeScript
  • ✅ Workspace configuration updated to include all sub-packages
  • ✅ Each sub-package can be built independently
Original prompt

新建前端实现的相关例子 在 examples/ui 文件夹下创建


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercel Bot commented Jan 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
spec Ready Ready Preview, Comment Jan 25, 2026 9:06am

Request Review

- Created package structure with package.json and tsconfig.json
- Added detailed README.md explaining UI protocol examples
- Created view.examples.ts: Grid, Kanban, Calendar, Gantt, Form views with various data sources
- Created action.examples.ts: Modal, Flow, Script, URL, and Batch actions
- Created dashboard.examples.ts: Sales, Service, Executive, Marketing, and Team dashboards
- Created page.examples.ts: Record, Home, App, and Utility pages with component composition
- Created app.examples.ts: Simple and comprehensive apps with hierarchical navigation
- Created theme.examples.ts: Light, dark, colorful, and accessibility themes
- All examples build successfully and demonstrate best practices

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Add new frontend implementation examples to ui folder Add comprehensive UI protocol examples in examples/ui Jan 25, 2026
Copilot AI requested a review from hotlong January 25, 2026 07:41
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tooling size/xl labels Jan 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR is very large. Consider breaking it into smaller PRs for easier review.

@hotlong

hotlong commented Jan 25, 2026

Copy link
Copy Markdown
Contributor

我是希望你能创建多个例子,你现在写的是一个元数据的例子,我还希望你再写一个如何实现自定义组件的例子。再写一个如何用react渲染json的例子。

@hotlong

hotlong commented Jan 25, 2026

Copy link
Copy Markdown
Contributor

我是希望你能创建多个例子,你现在写的是一个元数据的例子,我还希望你再写一个如何实现自定义组件的例子。再写一个如何用react渲染json的例子。

@copilot

Added two new example folders:
1. custom-components/ - Shows how to implement custom React components
   - CustomButton.tsx - Flexible button with variants and theming
   - CustomDataGrid.tsx - Advanced grid with sorting, filtering, pagination
   - Component registration system

2. react-renderer/ - Shows how to render UI metadata with React
   - PageRenderer - Renders Page metadata
   - ComponentRenderer - Renders individual components
   - Template expression resolver for data binding
   - SimpleApp example showing complete integration

Both examples include detailed READMEs with usage instructions

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

Copilot AI commented Jan 25, 2026

Copy link
Copy Markdown
Contributor Author

我是希望你能创建多个例子,你现在写的是一个元数据的例子,我还希望你再写一个如何实现自定义组件的例子。再写一个如何用react渲染json的例子。...

已添加两个新的示例文件夹:

  1. custom-components/ - 演示如何实现自定义React组件

    • CustomButton.tsx - 支持多种样式的按钮组件
    • CustomDataGrid.tsx - 高级数据表格(排序、筛选、分页)
    • 组件注册系统
  2. react-renderer/ - 演示如何用React渲染JSON元数据

    • PageRenderer - 渲染Page元数据
    • ComponentRenderer - 渲染单个组件
    • 模板表达式解析器(数据绑定)
    • SimpleApp - 完整集成示例

两个例子都包含详细的README文档和使用说明。提交 commit: 22d235e

@xuyushun441-sys

Copy link
Copy Markdown
Collaborator

@copilot 继续

Copilot AI commented Jan 25, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 继续

Could you please clarify what you'd like me to continue with? I've completed:

  1. ✅ Metadata examples (view, page, dashboard, action, app, theme)
  2. ✅ Custom components implementation (React)
  3. ✅ React renderer for JSON metadata

What additional examples or functionality would you like me to add?

@xuyushun441-sys

Copy link
Copy Markdown
Collaborator

@copilot ✅ Custom components implementation (React)
✅ React renderer for JSON metadata

os-bill pushed a commit that referenced this pull request Sep 18, 2026
…ON clause is derived

`CubeJoin.sql` was REQUIRED and described itself as the `ON` clause, and nothing
ever read it. Both analytics strategies synthesise the join — `NativeSQLStrategy`
emits `LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the
dotted member path alone, and `ObjectQLStrategy` resolves the join through
`cube.joins?.[alias]?.name` and lowers it to a relationship traversal with no `ON`
clause at all. So an authored join condition was not ignored, it was REPLACED under
a 200. `relationship` is the same shape one key over: it carried a
`.default('many_to_one')` and nothing dispatched on the cardinality.

`CubeJoinSchema` is a `strictObject`, so the route is strict deletion plus a
`guidance` prescription rather than a `retiredKey()` tombstone — the same route
`MetricSchema.filters` took in this file. The refusal names the key and states that
the `ON` clause is derived from the declared relationship between the two cubes'
objects. The `on` alias, which pointed at `sql`, becomes a `guidance` entry of its
own so an author is never sent to a key the shape cannot accept.

ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154
item 4, letter 2).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
os-bill pushed a commit that referenced this pull request Sep 18, 2026
…ON clause is derived

`CubeJoin.sql` was REQUIRED and described itself as the `ON` clause, and nothing
ever read it. Both analytics strategies synthesise the join — `NativeSQLStrategy`
emits `LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the
dotted member path alone, and `ObjectQLStrategy` resolves the join through
`cube.joins?.[alias]?.name` and lowers it to a relationship traversal with no `ON`
clause at all. So an authored join condition was not ignored, it was REPLACED under
a 200. `relationship` is the same shape one key over: it carried a
`.default('many_to_one')` and nothing dispatched on the cardinality.

`CubeJoinSchema` is a `strictObject`, so the route is strict deletion plus a
`guidance` prescription rather than a `retiredKey()` tombstone — the same route
`MetricSchema.filters` took in this file. The refusal names the key and states that
the `ON` clause is derived from the declared relationship between the two cubes'
objects. The `on` alias, which pointed at `sql`, becomes a `guidance` entry of its
own so an author is never sent to a key the shape cannot accept.

ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154
item 4, letter 2).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
os-bill pushed a commit that referenced this pull request Sep 18, 2026
…ON clause is derived

`CubeJoin.sql` was REQUIRED and described itself as the `ON` clause, and nothing
ever read it. Both analytics strategies synthesise the join — `NativeSQLStrategy`
emits `LEFT JOIN <name> <alias> ON "<parent>"."<segment>" = "<alias>"."id"` from the
dotted member path alone, and `ObjectQLStrategy` resolves the join through
`cube.joins?.[alias]?.name` and lowers it to a relationship traversal with no `ON`
clause at all. So an authored join condition was not ignored, it was REPLACED under
a 200. `relationship` is the same shape one key over: it carried a
`.default('many_to_one')` and nothing dispatched on the cardinality.

`CubeJoinSchema` is a `strictObject`, so the route is strict deletion plus a
`guidance` prescription rather than a `retiredKey()` tombstone — the same route
`MetricSchema.filters` took in this file. The refusal names the key and states that
the `ON` clause is derived from the declared relationship between the two cubes'
objects. The `on` alias, which pointed at `sql`, becomes a `guidance` entry of its
own so an author is never sent to a key the shape cannot accept.

ADR-0087 registration: the two exact keys in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3
semantic entry `cube-join-sql-and-relationship-retired`. Not a D2 conversion — there
is no consumer source to rewrite, and an author who wrote a non-FK condition wanted a
join the runtime does not perform, which is a judgement rather than a strip.

ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154
item 4, letter 2).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
os-tesla pushed a commit that referenced this pull request Sep 19, 2026
…row C9, judged after its effective instant

The protocol sanctions one hand-over: the holder's `Release:` line. The claim
arbiter ranked a second seat's claim under a first seat's live claim as a
SUPERSESSION at exit 0 — measured on twelve open cards across both boards.
Ruling b (batch #154 item 2): one named state beside C8, its own row and
remedy sentence, exit 4, judged only for pairs whose second claim postdates
the rule's landing; earlier pairs listed as informational, never red.

claimHandovers reads the live, attributable claims (claimRetractions is the
membership authority, as for C8 and governance) and names every point where
the author changes; C9 is the row when any taking claim is dated strictly
after CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT (the frozen UTC minute the row was
authored, naming its landing window), and C9-BEFORE-EFFECTIVE the note
otherwise. The record gains claim.handover from the same derivation. C8's
behaviour is unchanged; its direction-(b) pin now says C9 is the reader that
speaks. The twelve measured rows are replayed from the REST rows: ten are
listed today (none judged), two clear by the one-reading change alone, and
every one re-dated forward is judged.

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

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

Clause-②: yes (narrowing)

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

## What this does

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

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

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

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

## Proof of work: the shrink-only ledger

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

| reading | before | after |
|:---|--:|--:|
| `publishedSchemasWithDroppedRefinements` | **246** | **201** |
| `droppedRefinementSites` | **750** | **553** |
| `refinementSitesThatDidProject` | **0** | **197** |
| `refinementSitesWithNoJsonFormToCompare` | 3 | 3 |

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

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

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

## The contract: nothing the runtime accepts becomes refused, MEASURED

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Ablations — three, each restored with proof

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

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

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

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

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

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

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

## Verification

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

## Acceptance notes

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

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

## 维护者速读(草稿)

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

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

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

**席位意见** ——

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

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

---------

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

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

Clause-②: yes (narrowing)

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

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

## 1. The arm: `dependentRequired`

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

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

### Ledger: the rows retired, by name

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Ablation, both directions:**

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

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

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

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

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

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

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

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

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

## 5. Verification

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

## Acceptance notes

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

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

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

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

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

---------

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

Fixes objectstack-ai#18612

Clause-②: yes (narrowing)

Retires `sql` and `relationship` from `CubeJoin`. A cube join declares
WHICH object it
reaches; the ON clause is **derived** from the declared relationship
between the two cubes'
objects and is never authored. Per maintainer ruling `5725370783`
(director batch objectstack-ai#154 item 4,
letter 2), ADR-0049 enforce-or-remove. The other remedy — executing the
author's SQL — was
declined by that ruling and is ⛔ not reopened here.

## What this head carries — round 3 closed the one gap

The gap the earlier body described (`check:adr-0087-registration` RED on
purpose, and a fence on
`packages/spec/src/migrations/registry.ts`) is **gone**. The maintainer
answered that fork with
**A — lift the fence**, and the registration is now in-diff:

| what | where |
|:---|:---|
| D3 semantic entry `cube-join-sql-and-relationship-retired` |
`packages/spec/src/migrations/entries/semantic/18.*` |
| D2 conversion `cube-join-sql-and-relationship-removed` |
`packages/spec/src/conversions/registry.ts`, chained into
`step18.conversionIds` |
| `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CubeJoin:sql` and
`data/CubeJoin:relationship` | the two per-file retired-key entries |
| the changeset's disposition marker | `<!-- adr-0087: registered
cube-join-sql-and-relationship-retired -->` |

`check:adr-0087-registration` and `check:migration-registry` are both
**exit 0** on this head.

Round 3 added three things beyond the registration:

- **The artifact at rest heals at the boot door.** All three doors in
`packages/metadata/src/plugin.ts` run `_convertArtifactForward` before
the strict parse, so a
cube persisted with the old `{ name, relationship, sql }` shape is
converted rather than
refused. Pinned by `analytics.test.ts` — *"a persisted cube heals at the
door"* — with its own
  lit control and the per-cube notice paths.
- **`name`'s describe now states the convention it always had**: the
join KEY is the foreign-key
  field on the cube's own object, and the emission is
  `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id`.
- **The showcase join is re-keyed** `showcase_project` → `project`,
matching `task.object.ts`'s
`Field.masterDetail('showcase_project')`; `gap-fill.test.ts` pins every
join key against the
  base object's real field map rather than against a literal.

## The same measurement also chose the retirement ROUTE

The ruling says 「`retiredKey()` tombstones per the standing shape」.
`CubeJoinSchema` is a
`strictObject`, and for a strict shape AGENTS.md's standing shape is
**strict deletion plus a
`guidance` prescription**, not a `retiredKey()` tombstone — the route
`MetricSchema.filters`
took one shape over in this same file
(`packages/spec/src/migrations/entries/retired-keys/18.data__Metric__filters.ts`
states it in as many words). Measured both ways on this tree:

- `retiredKey()` tombstones: `check:authorable-surface` **exit 1** — *"2
key(s) were tombstoned
with no registered retirement"*, naming `data/CubeJoin:relationship` and
`data/CubeJoin:sql`
and demanding those exact lines in `RETIRED_KEYS_BY_MAJOR` (the fenced
file). Probe reverted;
  tree hash restored byte-identical to HEAD.
- guidance route: `check:authorable-surface` **exit 0**, adjudicating
the two baseline deletions
  under the objectstack-ai#4650 proof 4 it prints itself —
*"2 baseline deletion(s) since 84ba4a8 carry their own proof:
data/CubeJoin:relationship —
def reachable from the metadata-type roots; writing 'relationship' on it
is REFUSED as an
  unrecognized key"*, and the same for `sql`.
- ⏱️ Both readings above were taken by the dev in round 1 and re-taken
by the at-tier contract
review at head `e177aa2686`; this seat adopted that record at
2026-09-18T14:39Z
  (comment `5731599385`). ⛔ They are not this seat's own runs.

Either route needs the registration; it is now in-diff, in the table
above. The route choice is
independent of that registration, and is called out here so an at-tier
reviewer can overrule it cheaply.

## Acceptance legs, both readings

**LIT — an authored ON clause must be refused, in words a JSON author
reads**

| | `CubeJoinSchema.safeParse({ name: 'other', sql: 'a.id = b.a_id' })`
|
| --- | --- |
| before | **ACCEPTED** — parsed to
`{"name":"other","relationship":"many_to_one","sql":"a.id = b.a_id"}` |
| after | **REFUSED**, `unrecognized_keys`, message: *"…was removed in
@objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an
effect… Delete the key. A cube join has no authorable ON clause: it is
DERIVED from the declared relationship between the two cubes' objects,
as a foreign-key equality."* |

**DARK — a join that declares only its object must still parse**

| | `CubeJoinSchema.safeParse({ name: 'other' })` |
| --- | --- |
| before | **REFUSED** — `sql` was required (`invalid_type` at path
`sql`) |
| after | **ACCEPTED** — `{"name":"other"}` |

**Alias leg — `{ on: 'x' }`, read once before and once after**

| | reading |
| --- | --- |
| before | ``Unrecognized key(s) on this cube join: `on`. Did you mean
`on` → `sql`?`` |
| after | ``Unrecognized key(s) on this cube join: `on`.`` followed by
the derivation prescription, and **no rename suggestion** |

`aliases: { on: 'sql' }` is deleted rather than left pointing at a
retired key: an alias naming a
key the shape cannot accept answers the author with a second rejection —
the `triggerPhrase`
failure `packages/spec/src/shared/strict-object.ts` records. `on` now
carries its own `guidance`
entry, and both directions are pinned.

## The census the ruling took, re-taken — and one correction

The ruling recorded 「authored cube `joins` in hotcrm, objectstack
examples and cloud — **0 files**」.
Re-measured first-hand on this tree, **objectstack is not 0**:

- `examples/app-showcase/src/data/analytics/showcase.cube.ts` authors
**both** keys, including
`sql: '${showcase_delivery}.project = ${showcase_project}.id'` — a live
instance of the defect,
  an ON clause the runtime was silently replacing. Fixed here.
- Seven more authoring sites in `packages/services/service-analytics`'s
own test fixtures, found
by `tsc` after the keys left `z.input`, not by grep. Two of them
authored
`relationship: 'belongsTo'` — a value the enum never declared, which is
its own evidence that
  nothing validated or read the key. All fixed here.

This does not move the ruling: those are in-repo producers, fixed in
this same diff, and they
are what the retirement checklist calls for. It does mean 「zero
producers ⇒ no conversion is
owed」 rests on the external census only, and that half was **not**
re-measurable from here
(hotcrm and cloud are other repositories).

Consumer census, with a lit control, on this tree:

- reads of a join's `sql` anywhere in source: **0**
- reads of a join's `relationship` anywhere in source: **0**
(`native-sql-strategy.ts` was checked
  by name: it does not read either)
- lit control, reads of a join's `name`: **8** across
`native-sql-strategy.ts`,
  `objectql-strategy.ts` and `analytics-service.ts`

## What else moved, and why

- `packages/services/service-analytics/src/dataset-compiler.ts`
**constructed** both keys per
join (a constant `'many_to_one'` and a synthesised ON string). The
literal now carries `name`
alone; `parentAlias`, which existed only to build that string, is gone.
No read site changes —
`analytics-service.ts:1178` still reads `name` only, exactly as the
ruling said.
- The liveness ledger rows went **with** the keys
(`packages/spec/liveness/analytics_cube.json`),
which is the strict-deletion route's disposition and the opposite of the
tombstone route's.
`analytics_cube` drops 12 `dead` to 10; `state-counts.md` regenerated,
README notes cell
  rewritten to describe the set it now has.
- `content/docs/references/data/analytics.mdx` is regenerated, not
hand-edited. The `CubeJoin`
table is now one row and its description states the derivation — which
is the docs half the
  ruling asked for.
- `packages/spec/src/data/analytics-strictness-batchd.test.ts` keeps its
batch-D pin that an
**undeclared** join key is refused by name; the fixture drops the two
now-retired spellings so
the pin isolates what it always pinned. Three new pins beside it cover
`sql`, `relationship`
  and `on`.

## Verification

Two readings, kept apart on purpose — one is the reviewer's, one is this
seat's.

**① At-tier contract review, taken at head `e177aa2686`**, adopted by
this seat at
2026-09-18T14:39Z (comment `5731599385`), run in its own detached
worktree (fresh
`pnpm install --frozen-lockfile`, heavy steps under
`scripts/pm/os-verify-lock.sh`, exit codes
captured before any pipe). All exit 0: spec `build` · `check:generated`
(*"All 15 generated
artifacts are up to date"*) · `check:authorable-surface` ·
`check:liveness` ·
`check:migration-registry` (*"225 semantic, 195 retired-key, 181
retired-def"*) ·
`check-adr-0087-registration` and `--self-test` ·
`check-changeset-no-major` ·
`check:spec-docblock-symbol-anchors` (*"3130 anchors across 1462 spec
sources resolve"*) · eslint
over the 11 changed source/test files · `@objectstack/spec test` **488
files / 14190 tests** ·
`@objectstack/service-analytics test` **112 files / 2403 tests** ·
showcase `gap-fill.test.ts`
**13 tests** · typecheck for spec, service-analytics and the showcase ·
`check:exported-any`,
`check:yaml-examples`, `check:dual-source-exports`,
`check:entry-nameability`,
`check:browser-reachable-entries`, `check:skill-examples`, `check:i18n`,
`check:i18n-coverage`,
`check:i18n-walk-parity`.

That review — same adoption, ⏱️ 2026-09-18T14:39Z — returned **FAIL on
one mechanical
blocker and nothing else**, not a judgment defect.
The REQUIRED context `TypeScript Type Check` was red at `e177aa2686`
because
`check:api-surface-declarations` landed on main at `d8b12fca97`,
**after** this branch's
merge-base, so the branch carried neither the gate nor
`packages/spec/api-surface-declarations/`.

**② This seat's own reading of the fix, taken from the GitHub API at
head `7caf92189a`**
(⏱️ 2026-09-18T14:59Z 取): commit `59bd587aea` merges `origin/main`, and
`7caf92189a`
regenerates the shards. The API reports that commit as
`{"total":30,"additions":0,"deletions":30}`
over exactly three files — `api-surface-declarations/data.txt` −12,
`root.txt` −12,
`system.txt` −6. A **pure deletion**: ⛔ not one line was added, so
nothing was hand-written into
a generated artefact. That is byte-for-byte the shape the review
predicted (each reshaped
declaration loses `sql: z.ZodString;` and the `relationship` enum block,
propagated by type
inlining).

CI at this head, ⏱️ 2026-09-18T14:59Z 取: **0 failing check runs out of
33**.
`Build Core`, `Dogfood Regression Gate`, `Temporal Conformance (live PG
+ MySQL)` and
`Governed Surface Queue Guard` are success; `Lint & Repo Gates` is in
progress;
`TypeScript Type Check` and `Test Core` have not reported yet. ⛔
Not-yet-reported is **not**
passing, and this PR is not landed on that basis.

⛔ Not a complete account of what CI runs here: the 50 artifact-roster
families, the 11 declared
wide-population families, the 6 path-scheduled CI jobs and the
always-runs tail each sit outside
any derived total above. Not measured anywhere: repo-wide `pnpm test` /
`pnpm typecheck`,
`check:dual-build-cjs-loads`, and the external hotcrm / cloud census
(other repositories).

**⚠️ Landing-order note, so nobody is surprised.** PR objectstack-ai#19024 (the
maintainer's, `priority:p1`)
reverts objectstack-ai#18971 and **deletes all 17 declaration shards**. Whichever of
the two lands second must
merge the other first; if objectstack-ai#19024 goes in ahead of this PR, the
regeneration commit above becomes
moot and its three files disappear with the rest of the snapshot. ⛔ That
is a mechanical merge,
not a defect in either diff.

## Acceptance notes

Noted, not filed — observations, no card:

- `packages/spec/liveness/analytics_cube.json` still records `public` as
an access-control flag
that gates nothing and `refreshKey.every` / `refreshKey.sql` as a
caching block with no
scheduler. Both are already recorded there with their measurements;
ADR-0049 wants a decision
on each, and neither is this card. Successor: whoever picks up the
`analytics_cube` ledger's
  remaining `dead` rows.
- `AnalyticsQueryRequestSchema` reaches `CubeJoinSchema` only through
`CubeSchema`, so no REST
  request surface changes. Successor: none.


---
_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
…rces (objectstack-ai#19137)

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

Clause-②: yes

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

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

---

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

## 1. The arm

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

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

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

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

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

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

## 2. The rows retired, by name

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

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

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

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

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

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

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

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

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

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

After:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 7. Verification

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

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

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

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

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

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

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

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

## Acceptance notes

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

---

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

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

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

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


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

---------

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

Fixes objectstack-ai#18698

Clause-②: no

One clause in `.claude/agents/os-dev.md`, a governed Tier S surface
(`.claude/**`): landing is the skills seat's contract-tier review of
record through the queue. This PR stops at draft.

## What changed

`.claude/agents/os-dev.md`, Definition-of-done list, right after the
trailer line — today `:284`, kept byte-identical because
`scripts/check-commit-card-trailers.mjs` cites that sentence verbatim
and its self-test holds the citation to the file. New `:285` (120 B):

> - harness 归属提醒凭其优先级句让位本文件;harness 自写含模型名 trailer 只报,⛔ 不仿不改史。

It restates the governing text, `AGENTS.md` :440–:444 (quoted with the
two angle-bracket placeholders spelled out in words, because GitHub
mutates body bytes):

> **Commit message:** an agent commit ends with the model-free trailer
pair `Claude-Session: https://claude.ai/code/session_ID` and
`Co-authored-by: Claude (noreply at anthropic.com)`, and the pre-push
hook refuses a model identifier in that pair; no model identifier lands
in a PR title or body, a comment, a changeset, a doc or a code comment.
Two exemptions: a harness-written `Co-Authored-By` trailer (REPORTING:
not declared a deviation; landed history is not rewritten) and a
verbatim maintainer ruling preserved as a quotation block.

The precedence sentence the clause relies on is in the harness
attribution reminder itself — read in this session, verbatim: 「the
user's own instructions about these lines, such as a CLAUDE.md or memory
rule, take precedence over this reminder」. `CLAUDE.md → AGENTS.md` is
that instruction, so the reminder defers; the clause restates AGENTS.md,
never the reminder.

## Ruling — director record on the card (batch objectstack-ai#154 item 5, maintainer
「同意」 2026-09-18T04:56Z), operative part verbatim

> - `.claude/agents/os-dev.md:283` gains the one clause that would have
saved the escalation: **the harness attribution reminder yields to this
file by its own precedence sentence; a trailer the harness itself writes
with a model name is `AGENTS.md`'s exemption — report it, ⛔ do not
imitate it, ⛔ do not rewrite history.** Line-neutral; on the os-dev.md
serial queue behind objectstack-ai#18599, in one round with objectstack-ai#18699 as triage
suggested.
> - ⛔ **B** — contradicts `AGENTS.md:442` and the hook, and lets a
reminder that changes without a PR drive landed history in four
repositories.
> - `Clause-②: no`; `skip-changeset`; governed surfaces ⇒ human merge by
the approver.

Readings, not identifiers: the ruling's `:283` reads `:284` on today's
`main` (the file moved when PR objectstack-ai#19038 landed at 2026-09-18T23:52Z); the
serial 「behind objectstack-ai#18599, with objectstack-ai#18699」 is discharged — both landed before
this PR (PR objectstack-ai#18725 and PR objectstack-ai#18898). The landing route is the later Tier S
ruling (the seat's contract-tier review of record through the queue),
superseding the last bullet's human merge.

## Line budget — ratchet row `['.claude/agents/os-dev.md', 403]`

| reading | before (`5d0ee8f`) | after (`9612c8d`) |
|---|---:|---:|
| lines | 403 | 403 |
| widest line | 120 B | 120 B |
| new clause | — | 120 B at `:285` |
| diff | — | +1 / −1, one file |

**Payment (line-neutral):** the removed bullet is the former `:285`
「标题与散文用英文(见 AGENTS.md);引用的中文裁决保持原文不译,改写引文就是改写裁决。」 — a true duplicate
that cited its own source. Its content survives verbatim in `AGENTS.md`
§ Communication: `:25` 「**GitHub 产物一律使用英文**:issue 与 PR 的标题、正文、评论。」 and
`:26–:27` 「**引用中文裁决时保持原文、不翻译**,即使承载它的 issue/PR 正文通篇是英文——改写引文就是改写裁决。」;
os-dev.md `:19` already binds AGENTS.md before the first edit. No
re-wrap bought the line and no ceiling moved. Measured before choosing:
zero gate scripts pin any phrase of the removed line (grep over
`scripts/`, `.github/`, `.claude/skills/`, `package.json`); the only
pinned sentence in this list is `:284`, which is untouched
(`check-commit-card-trailers` self-test: 「✓ every sentence inside the
corner brackets is verbatim in the cited rules file」).

The clause names no model and no card. Frontmatter untouched (`model:
opus` line unchanged). The wording was measured down from the seat's 157
B suggestion; the three-verb prohibition without objects follows the
file's own register (`:303` 「⛔ 不挂不摘不等」).

## Verification — all at `9612c8d`, every exit code captured before any
pipe

Gate list derived in the worktree with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` (change set: `.claude/agents/os-dev.md` only, 2 changed
lines; 19 commands — identical to the dispatch's 19) and reconciled with
`--ran`: 「✓ dispatch-gates --ran: 19 derived famil(ies) accounted for —
19 run, 0 NOT-MEASURED」.

| command | exit | the gate's own verdict line |
|---|---:|---|
| `node scripts/check-closing-keyword-parity.mjs` | 0 |
check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and
both measured separators; sweep found 5 file(s) carrying the grammar
across 8991 tracked file(s), all registered). |
| `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | ✓ 40
assertions, 5 mutations of the shipped parsers each driven to red. |
| `node scripts/check-comment-mask-corpus.mjs` | 0 | ✓ comment-mask
corpus sweep: 6894 files, 0 disagree, 0 unparseable. |
| `node scripts/pm/check-governed-queue-guard.mjs --self-test` | 0 | ✓
check-governed-queue-guard self-test: 261 cases pass. |
| `node scripts/pm/check-harness-current.mjs --self-test` | 0 |
check-harness-current --self-test: all 26 cases passed. |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
0 | ✓ 14 predicate(s) on a statically determinable field layer judged
clean; 6 skipped as undeterminable. (First run refused with exit 3
PREREQUISITE NOT MET — `@objectstack/formula` and `@objectstack/lint`
unbuilt in a fresh worktree; built both under the verify lock, `VERDICT
command-exit 0`, then this reading.) |
| `pnpm check:agent-model-declared` | 0 | ✓ 1 agent definition(s) under
.claude/agents/ all declare a model — os-dev.md → opus. |
| `pnpm check:agent-test-spelling` | 0 | ✓ 0 violations — 516 file(s) ·
9159 bare `--` token(s) · 13 separator(s) JUDGED. |
| `pnpm check:commit-card-trailers` | 0 | ✓ check-commit-card-trailers
self-test: 81 cases pass (incl. 「every sentence inside the corner
brackets is verbatim in the cited rules file」). |
| `pnpm check:cross-package-test-inputs` | 0 | OK: 29 package(s) read
outside themselves, all declared. |
| `pnpm check:doc-authoring` | 0 | ✓ doc authoring guard: 402 files
clean — no bare metadata literals (+ the three sibling verdict lines,
all ✓). |
| `pnpm check:driver-memory-census` | 0 | check-driver-memory-census: OK
— every declaration is ledgered, every ledger entry is live. |
| `pnpm check:nul-bytes` | 0 | check-nul-bytes: OK (scanned 8984 text
file(s); no raw ASCII control bytes). |
| `pnpm check:pm-governed-merges` | 0 | ✓ check-governed-merges
--self-test: 392 assertions. |
| `pnpm check:pm-skill-id-lint` | 0 | ✓ check-skill-id-lint: 27 file(s)
clean (pattern /#[0-9]{3,}/g). |
| `pnpm check:pm-skill-ratchet` | 0 | ✓ .claude/agents/os-dev.md is 403
lines (ceiling 403; headroom 0). ✓ widest table row is 0 bytes (pin 0).
|
| `pnpm check:refd-timer-probe` | 0 | OK: 6889 source file(s) swept; the
probe is read in the approved module and nowhere else. |
| `pnpm check:skill-frame-sync` | 0 | ✓ the one declared copy of the
decision frame is internally coherent; 74 markdown files scanned for
undeclared copies. |
| `pnpm check:watch-hint-literal` | 0 | ✓ 71 declaration(s) across 4
rostered name(s), every one an array of quoted literals. |
| `pnpm check:pm-settings-deny-roster` (extra — the derivation flagged
its roster as living under `.claude`) | 0 | ✓ 17 content-write tool(s)
declared = enforced in .claude/settings.json. |

Manual readings on the file: `wc -l` 403; `awk` widest 120 B; `grep
-naP` for raw control bytes: no output (exit 1); `grep -c` of the
removed line's text: 0. Pre-push hook on the push: 「✓
check:commit-card-trailers: 1 commit message(s) on this push carry no
card relation and no model identifier in the trailer pair.」

Package tests / typecheck: none owed — the diff touches no package (no ①
closure, no ② suite); the two-package build above was a gate
prerequisite only. Repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Live instance of the clause, reported, not a deviation.** This
session's harness attribution reminder asked for a model-bearing
co-author trailer and a different PR footer block. Per the reminder's
own precedence sentence and `AGENTS.md` :440, the commit carries the
model-free pair and this body carries the session-URL footer; nothing
was rewritten.
- noted, not filed: `check:doc-formula-expressions` exits 3
(PREREQUISITE NOT MET) in any fresh worktree until
`@objectstack/formula` and `@objectstack/lint` are built — the gate says
so itself and names the fix; by design, not a defect. 承接者:无.
- Context on the card that this PR does not act on (the devx seat's
evidence): CI runs `check-commit-card-trailers` as `--self-test` only,
so the pre-push hook is the only leg that refuses a model-bearing
trailer; a session without `.githooks/` installed pushes one unrefused.
Listed in the report's `out_of_scope_findings` as a class-(b) candidate
with dedupe words for the triage seat; no card is opened here.
- Out of scope, untouched: `AGENTS.md`, every `SKILL.md`, the hook, CI.
objectstack-ai#18599 and objectstack-ai#18699 remain landed history; objectui#9441 is the ui lane's
twin of the ruling.

## 维护者速读(草稿)

- **改了什么**:`.claude/agents/os-dev.md` 的 Definition of done 列表,在 trailer
那一行之后新增一条(120 B):harness 的归属提醒按它自己的优先级句让位于本文件;harness 自己写入的含模型名 trailer
是 AGENTS.md 的例外——只报告,不仿写、不改历史。为守住 403 行上限,删掉了一条原文照抄
AGENTS.md「沟通」节的重复条目(GitHub 产物用英文;引用中文裁决不翻译),该内容在 AGENTS.md :25–:27 原样保留。
- **为什么改**:每一次派发,dev 都同时收到两份指令——仓库契约(model-free trailer)与 harness
注入的归属提醒(含模型名)。已有 dev 因此写不出 commit、只能升级上报。答案其实早就写在 AGENTS.md
里,也写在提醒自己的优先级句里;这一条把答案放到 dev 真正读的那一行旁边,省掉下一次升级。
- **风险与代价(含回滚)**:零机制变化——不动 hook、不动 CI、不动 frontmatter;行数 403/403、最宽 120 B
不变,19 个派生门禁全绿。风险只在措辞:若你觉得「只报,⛔ 不仿不改史」过于压缩,可在同一行内换词(须 ≤ 120 B)。回滚 =
revert 这一个 commit(1 行换 1 行)。
- **席位意见**:
- **你要做的**:无需你点击合并——本 PR 在 `.claude/**`(Tier S),由 skills
席位按合约档复核记录经队列落地。若对措辞有意见,在本 PR 留一句即可。

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…he shared reading, the prose channel retired, cross-author live claims named (row C9) (objectstack-ai#19169)

Fixes objectstack-ai#18862
Fixes objectstack-ai#18829
Part of objectstack-ai#18773 — the READER half only. The governed-text half of ruling
A (AGENTS.md's release clause, SKILL.md's yielding line at its line
ceiling, the core-rules.md twin) remains open on that card: Tier H, held
by the seat, no governed file is touched here.

Clause-②: no

## What this PR does

One skills round, one PR on `scripts/pm/check-clause2-carriers.mjs`, as
the three director records of the maintainer's 「同意」 order (batches objectstack-ai#154
/ objectstack-ai#156). The claim arbiter used to infer three ownership acts the
protocol never declared — a supersession between seats, a prose
retraction, a stripped prefix — and this PR makes it read the declared
act only.

1. **Cross-author live claims with no `Release:` between — row C9**
(objectstack-ai#18862, ruling b, 5725370464). `claimHandovers` reads the live,
attributable claims (membership decided by `claimRetractions`, exactly
as C8 and governance read it) and names every point where the author
changes. A hand-over whose taking claim is dated strictly after
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT = '2026-09-19T03:45Z'` (the frozen
UTC minute the row was authored — it names the rule's landing window;
the seat reconciles pairs between it and the merge by hand) is a **C9
row, exit 4**, with the ruling's remedy sentence: the holder posts
`Release:`; the taker posts nothing until then; a taker that yields
posts its own `Release:` with 去向 「让先到者」. A hand-over dated at or before
the instant is a **`C9-BEFORE-EFFECTIVE` note** and a `claim.handover`
record field — listed, informational, never the exit. C8 is unchanged in
behaviour; its direction-(b) pin (silent on cross-author) is replaced by
the pin that C9 is the reader that speaks — never both for two authors
with one claim each.
2. **One undecoration definition** (objectstack-ai#18829, ruling A, 5725677876). The
retraction channel reads `Release:` through the sibling's
`markerMatches`, never through a stripper of its own; the second
stripper is deleted from the tree. The sigil-led objectstack-ai#18373 shape is a
**named near-miss row, `leading-sigil`**, in
`OWNERSHIP_MARKER_NEAR_MISS_FORMS` (`scripts/pm/check-half-states.mjs`,
one roster member and its BATTERY68 pins — no predicate, no H-row, no
other export, per the claim amendment 5738916776). The twelve spellings
the card compared now read the same on every side of the reader (5
disagreements → 0, pinned on the pool and on the retraction index); `-
Claim:` / `* Claim:` stay not a claim (H20).
3. **The prose retraction channel retires** (objectstack-ai#18773, ruling A,
5725677697 — reader half). The verb roster, the comment-id scan and the
act-verb reading PR objectstack-ai#18770 taught are deleted, not disabled. A
retraction is read only as a `Release:` line by the claim's own author,
later than the claim; it needs no comment id (it names its target by
authorship) — that requirement did not exist before and is not added.
The objectstack-ai#18373 specimen is replayed both ways: the prose line no longer
retracts (the withdrawn claim stands again and governs — the cost ruling
A priced and accepted, ⛔ C), the same line in the declared act takes it
out, and the thread is **listed by C9's note** rather than read
silently.

## Measured, not assumed

- **A live consequence of item 2, replayed from the REST rows (read
2026-09-19T03:30Z through the proxy):** four of the twelve pairs carried
the earlier holder's `Release:` all along, bolded or backticked — objectstack-ai#17852
(os-warren, 5700605769), objectui#7804 (os-justin, 5707789209 and
5710740327), objectui#7848 (claude[bot], 5617804323), objectui#7924
(os-warren, 5613413938) — and the raw `RELEASE_COMMENT_MARKER.test` the
retraction channel used could not see any of them. With the shared
reading, **objectui#7848 and objectstack-ai#7924 clear by this change alone** (one
author left, nothing named, no seat posts anything); on objectstack-ai#17852 the pair
named today is os-litant → os-elon-musk (2026-09-18T22:04Z), not the
os-warren / os-litant pair the sweep counted; on objectui#7804 what
stands is os-tesla ×4 (C8) and os-sam.
- **The ten remaining measured pairs** are all listed today (none judged
— every taking claim predates the instant), and every one re-dated sixty
days forward is judged: the row is reachable on the real shapes, not
only on synthetic ones. objectui#9370's 「⛔ NOT a re-claim」 line is named
anyway — the reader reads order, not intent.
- **objectstack-ai#18373** (the objectstack-ai#18719 specimen) becomes a listed cross-author pair
once the prose channel retires (os-litant → os-bill, 2026-09-17),
cleared by os-bill's own `Release:` — added to the seat's reconciliation
list below.
- **Offline acceptance runs** (`--pair-json`, head `4cb58bc`): a
synthetic pair with the taker dated after the instant → `exit 4`, `✗ C9
—` with the remedy sentence; the real objectstack-ai#15811 rows → `exit 0`, `ℹ️
C9-BEFORE-EFFECTIVE —` and `claim.handover: … LISTED`; two claims by one
author → `exit 4`, `✗ C8 —` only, `claim.handover: none`.

## Verification

- `node scripts/pm/check-clause2-carriers.mjs --self-test`: **978 → 1046
cases pass**, exit 0 (head `4cb58bc`). Battery roster **33 → 34**
(`objectstack-ai#18862: cross-author LIVE claims…`, floor 52); `objectstack-ai#18719` battery
rewritten around the one channel (floor 36 → 46 by count); `objectstack-ai#18764`
two-paths pins replaced by the twelve-spelling replay; `objectstack-ai#18828`
direction (b) replaced.
- `node scripts/pm/check-half-states.mjs --self-test`: **5043 → 5063
cases pass**, exit 0; `H2/H47/H66 decorated ownership marker` 193/184 →
213/184; roster pin `…separator,leading-sigil,heading-bare,bare-word`.
- **Reverse verification, both files, through
`scripts/ablation-replace.mjs` (mutation proven on disk, restored to the
HEAD blob, `git diff HEAD` empty after each):** `judged: dated ===
'after',` → `judged: false,` in clause2 reds **21 of 1046** cases (every
judged-direction pin); `id: 'leading-sigil',` → `id:
'leading-sigil-ablated',` in the sibling reds **6 of 5063** (the
exact-roster pin and the form's own pins).
- **Absence, tree-wide at `4cb58bc`:** `git grep -n
undecorateRetractionLine` → 0 (control `undecorateProseLine` → 20); `git
grep -n RETRACTION_PROSE_ANCHORS` → 0 (control `RELEASE_COMMENT_MARKER`
→ 64). No control bytes in either file (`grep -naP` over the C0/DEL
range → 0).
- **Lint, narrowed and measured** (head `4cb58bc`): `npx eslint
--no-inline-config --format json` over the two changed files → exit 0,
**2 files, 0 errors, 0 warnings**. Population: `eslint.config.mjs`'
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` object minus `NEVER_LINTED`, so
both `.mjs` files are in it; the count is the JSON output's length (2).
Invariance: the config states it 「never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules) for ANY
file」, so this diff moves no untouched file's verdict — the narrowing
excludes nothing.
- **Derived gate families** (`node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` from the merge-base: 42 commands
— eight more than the dispatch's list, pulled in by the
`check-half-states.mjs` touch — plus `check:pm-governed-merges`, named
by the dispatch): the sweep was in progress when this PR was opened;
every exit code, with the `--ran` reconciliation, is in the
`os-dev-report` comment on objectstack-ai#18862.

## Acceptance notes

- **For the seat's reconciliation sweep (ruling item 2, the seat's
act):** objectui#7848 and objectstack-ai#7924 need no `Release:` — they clear on
landing; objectstack-ai#17852's live pair is now os-litant / os-elon-musk;
objectui#7804 is os-tesla / os-sam beside os-tesla's C8; objectstack-ai#18373 (closed)
joins the informational population as os-litant / os-bill.
- **Deviation from the dispatch's mechanism assumption, reported rather
than forced:** the dispatch expected the objectstack-ai#18373 prose line itself (「🚨
**撤回上一条认领…」) to be reported by the near-miss row. It cannot be without
reading the verb 撤回 — the channel ruling A on objectstack-ai#18773 retires (⛔ B). So
the named row is the sigil-led spelling of the declared act (the same
line with `Release:` in place of the verb, sigil and bold intact —
pinned as `leading-sigil` in both files), the prose specimen is pinned
as named by no form with the reason, and its thread is loud through C9's
note instead. The ruling's intent — the shape is named, not stripped;
the thread is not silent — holds on both counts.
- The live `--pair N` path: node's built-in fetch does not read
`HTTPS_PROXY`; the script already prints the `--use-env-proxy` hint.
Noted, not filed — not a defect in this reader. 承接者:无.
- `--pair-json` documents that omit the PR's own comment thread read
UNJUDGED (exit 2) on the record read — by design; the acceptance runs
above supply it.

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

---------

Co-authored-by: os-dev <yinlianghui@steedos.com>
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ConfigSchema.assignments (objectstack-ai#17852, objectstack-ai#18847) (objectstack-ai#19147)

Fixes objectstack-ai#17852
Fixes objectstack-ai#18847

## What

Implements maintainer ruling **A, narrow** (comment 5725370319, batch
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — b459d62c Deployed Jan 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants