Repository navigation
Add comprehensive UI protocol examples in examples/ui - #154
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- 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>
|
This PR is very large. Consider breaking it into smaller PRs for easier review. |
|
我是希望你能创建多个例子,你现在写的是一个元数据的例子,我还希望你再写一个如何实现自定义组件的例子。再写一个如何用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>
已添加两个新的示例文件夹:
两个例子都包含详细的README文档和使用说明。提交 commit: 22d235e |
|
@copilot 继续 |
Could you please clarify what you'd like me to continue with? I've completed:
What additional examples or functionality would you like me to add? |
|
@copilot ✅ Custom components implementation (React) |
…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
…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
…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
…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
…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>
…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>
…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>
…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>
…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>
…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>
…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>
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:
2. custom-components/
React component implementations showing how to build custom UI components:
3. react-renderer/
Demonstrates how to render UI metadata using React:
{field.path}syntaxOrganization
All three sub-packages have their own
package.json,tsconfig.json, and comprehensive README documentation, creating a clean separation between:Testing
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.