fix(spec): retire tenancy.organizationField from the authorable surface (#19054) - #19618
Conversation
Strict removal from TenancyConfigSchema + guidance row, the D2/D3 registration, the liveness ledger row, and the platform-internal stamp table that replaces limb 0 in metadata-core. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…lsifies Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
check-issue-citations judged 12 citations this change adds; #8778 and #8707 are allocated-but-absent on the board. The rulings they named are cited in prose and by the cloud record that does resolve. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9f02cde4c5a407d65a9637c05484bdcad66a7880 && git checkout 9f02cde4c5a407d65a9637c05484bdcad66a7880
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b23e4fab548c43c17754925fe0e1c66dbacd99c 3fb1a4994cb5cfe66f4d67f042213940a9158f4c && git checkout -B drift-repro 4b23e4fab548c43c17754925fe0e1c66dbacd99c && git merge --no-ff 3fb1a4994cb5cfe66f4d67f042213940a9158f4c
node scripts/docs-audit/affected-docs.mjs --json 4b23e4fab548c43c17754925fe0e1c66dbacd99c
|
Contract reviewServed-tier: Reviewed from the diff and the tree, not from the PR narration. Every zero below is paired with the radius it was taken over plus a known target outside it. ① Derived judgmentsAC1 — met. AC2 — met, and it measures the prescription. I traced the channel rather than taking the ablation's word: AC3 — met. The end-to-end half I verified by reading the other two writers' fixtures rather than trusting "untouched": AC4 — met. D2 AC5 — ⭐ NOT met as the card words it. This is the finding. Sanctioned writer #1's pin was edited:
I hold this as a declared, forced finding rather than a block, on three measured grounds: the shape is unreachable for the shipped table ( ② Semver level
③ Boundary flags1. The serial collision is accurately declared, and the file is genuinely generated. #19610's file list does contain 2. The four out-of-surface files — each forced, none bent.
3. The ablations prove what they claim — with one radius named. The second is independently corroborated from source: the refusal pin carries five 4. The unresolvable citations — re-measured, and the PR's claim about them is imprecise. 5. The residue list checks out; one item it does not name. The three sanctioned writers' fixtures still declare the retired key but stay green for the reason given in ① (all keyed 6. CI state at review time (2026-09-21T18:43Z), measured at this head rather than taken from the PR. 39 check runs: 34 Implemented-by: VERDICT: PASS Generated by Claude Code |
更正
|
…ganization-field Base merge only; regeneration follows as its own commit. Hand-resolved (text conflicts, both intents stacked): - packages/spec/src/conversions/registry.ts: CONVERSIONS_BY_MAJOR[18] keeps main's translationPerAppSettingsRemoved and appends this branch's objectTenancyOrganizationFieldRemoved after it (application order within a major = landing order). - packages/spec/src/migrations/registry.ts: step18.rationale keeps the shared closing line once (trailing space), main's translation-bundle paragraph verbatim with its last line re-terminated as a continuation, then this branch's tenancy.organizationField paragraph; step18.conversionIds keeps 'translation-per-app-settings-removed' and adds 'object-tenancy-organization-field-removed'. Driver-deferred (os-regen, regenerated in the next commit): - content/docs/references/system/migration.mdx - content/docs/references/data/object.mdx Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
…dd13) Discharges the os-regen deferral recorded on the merge commit. The driver kept this branch's side of both files; main's side was restored and the pages were regenerated from the merged tree with `pnpm --filter @objectstack/spec build` then `gen:docs`. - content/docs/references/data/object.mdx - content/docs/references/system/migration.mdx Result versus origin/main differs only by this branch's own tenancy.organizationField removal (same changed lines as the branch's pre-merge diff against its merge base). check:generated: all 15 current. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
…at carries it check:future-spec-major (on main since this branch's old base) refuses the prescription's "@objectstack/spec 18": ADR-0087 (amended 2026-09-13) has a tombstone name the npm release it ships in, never the protocol major. This retirement ships as a pre-GA minor, so the carrier is the bare published major, 17. The refusal pin follows the text. Protocol-major references (`os migrate meta --from 17`, `toMajor: 18`, `RETIRED_KEYS_BY_MAJOR[18]`) are unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
等项目总监契约复审 —— 维护者指示,2026-09-23T06:52Z
出处三件
这意味着什么本 PR 的达档契约复核曾由席位三次尝试起子代理,分别在 2026-09-23T04:15Z、2026-09-23T05:23Z、2026-09-23T06:22Z,三次都因账户本周复核档额度用尽(HTTP 429)在产出任何记录前终止,三次均作废,无可采纳之物。按上述指示,席位不再重试。本 PR 保持 draft、队列外,等项目总监席召唤时做契约复审。 总监复审时可直接用的现状(head
|
Contract reviewServed-tier: Rendered by the director seat (summon #27, ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS — the contract judgments (accept set narrowed as ruled, public surface unwidened, semver and D2 consistent). What remains before Generated by Claude Code |
维护者裁定:倒置对照「同意」—— 2026-09-23T07:21Z
出处三件
裁定的对象
落地前提,逐条对账(head
|
… (ruling item 2 + census) (objectstack-ai#19720) Part of objectstack-ai#17418 — this is the **spec/scripts half** of the ruled change. It deliberately does not close the card; ruling item 1 (`packages/cli`) is a sibling PR by the `domain:cli` seat and is listed below with its real file surface. Clause-②: no Ruling: comment 5644350230 (director seat, decision batch objectstack-ai#122 item 1, maintainer 「同意」 2026-09-12). ## The lane split, and what is NOT here | ruling item | this PR | |:--|:--| | 1. `TEMPLATES` in `packages/cli/src/commands/init.ts` emit the factory shape; `content/docs/deployment/cli.mdx:1323` describes it | **no** — `domain:cli` sibling; surface measured below | | 2. a `*.object.ts` not using the factory is refused **by name** — "use `ObjectSchema.create`" | **yes** | | 3. census in-repo literal-shaped object files and convert them | **yes** — census result: zero to convert | No file under `packages/cli` is touched. `content/docs/deployment/cli.mdx` is not touched. `packages/platform-objects/src/identity/sys-api-key.object.ts` (held by objectstack-ai#19618) is not touched — the census did not name it, so no serialization was needed. ## Landing order — measured, not assumed The hazard to rule out: if the gate refuses the annotated-literal shape while `os init` still emits it, a freshly scaffolded project would be born refused. **It does not arise. Landing item 2 alone turns nothing red that only item 1 can fix.** Four readings, all on `origin/main` at `4fba5036f2`: 1. **The gate's population is a filename suffix, not a shape.** `walkObjectFiles` collects files whose name ends `.object.ts`, repo-wide minus `SKIP_DIRS`. Measured: **112 files, zero of them under `packages/cli`** (`git ls-files | grep '\.object\.ts$'` also returns 112, so nothing untracked is hiding either). 2. **`TEMPLATES` are string literals inside `packages/cli/src/commands/init.ts`** — a `.ts` file, not a `*.object.ts` file. The walk matches by filename, so the template strings are never read by this gate, in any shape. 3. **Every CLI scaffold test writes into `os.tmpdir()`**, never into the repo tree (`mkdtempSync(join(tmpdir(), …))` throughout `packages/cli/test/`). No test run can transiently materialise a literal-shaped `*.object.ts` inside the walk, and the tree carries no ignored one either. 4. **`os init` emits into a user's project**, which does not carry this repo's `scripts/`. So the seat's reading holds, and it is now a measurement rather than a reading. Items 2 and 3 land independently; item 1 follows on its own card. ## Item 2 — the rule, and the hole it actually closes The gate found object declarations by `CREATE_CALL` (`ObjectSchema.create(`) and nothing else. One backstop existed: a file yielding zero declarations **and** zero refusals is refused. That backstop is conditioned on `objects.length === 0`, so a file holding a factory declaration **and** a literal one read as complete, and the literal one was judged by nothing at all. Measured on a two-file control tree, with the pre-change gate: ``` === BASE GATE over the control tree === files walked : 2 objects PARSED : 1 -> ctrl_factory refusals : 1 packages/ctrl/src/objects/pure_literal.object.ts:1 no object declaration recognised in a `.object.ts` file. ``` `mixed.object.ts` produced nothing — and the literal declaration inside it (`ctrl_hidden`) keys a **UNIQUE index on an unbounded `text` column**, which is the exact defect this gate exists to catch. That is the "never silently unprotected again" the ruling names. The authoring-shape scan now runs **independently of** the factory parse, over every `*.object.ts`. Its refusal, verbatim from the real gate binary: ``` check:keyed-text-bounds: 2 object declaration(s) not written with `ObjectSchema.create` packages/platform-objects/src/__shape_demo__/mixed.object.ts:10 `ctrlHidden` is declared as a plain object literal — use `ObjectSchema.create({ ... })`. Recognised as a declaration by a `ServiceObject` type annotation, and a literal `name:` beside a `fields:`, the two keys the factory parser requires. `ObjectSchema.create` is the one authorised shape for a `.object.ts` declaration: it parses the declaration against ObjectSchema when the file is evaluated, so an error surfaces where it was written. A typed literal defers every check to a build the author may never run — and this gate cannot read it at all, so every keyed text column in it goes unjudged. The conversion is mechanical: wrap the literal in `ObjectSchema.create( ... )`. ⛔ Do not teach this scan the literal shape instead — the shape is refused, not unknown. ``` Four signals, published in the file header because a source scan sees only the spellings it knows: a `ServiceObject` annotation (any indentation), a `satisfies ServiceObject` (any indentation), the file's default export (top level), and a literal `name:` beside a `fields:` (top level only — held to column 0 so a helper literal built inside a function and handed to the factory is not accused). The zero-declarations backstop now steps aside when the shape scan already named the reason, so a literal-shaped file gets **one** finding prescribing the factory rather than a second one inviting the parser to be widened. ## Item 3 — the census: instrument, control, reach The card's "112 parsed, all factory" and the seat's "112 files on main" are **not the same fact**, and a literal-shaped file the gate cannot parse is invisible to exactly the instrument the card used. So the census was taken with a separate instrument. **Instrument** — over-inclusive and shape-agnostic: for every `*.object.ts` in the same walk, list every top-level binding of an object literal or of a call, plus every `export default`, then subtract the ones whose initializer is `ObjectSchema.create(`. Whatever is left is a candidate for hand triage. It does not depend on knowing the spelling `Data.ServiceObject`. **Result:** ``` *.object.ts files walked: 112 top-level bindings/defaults seen: 120 initialized by ObjectSchema.create(: 117 initialized by a bare OBJECT LITERAL: 0 <= CANDIDATES for item 3 initialized by something else: 3 CANDIDATES: none. ``` The 117 matches the gate's own parsed count exactly, from a different reader. The 3 non-literal bindings were printed rather than counted, and are plainly not declarations: a regex literal, a template string and `'sys_http_delivery' as const`. **Control (lit):** the same instrument over a two-file control tree returns 2 candidates — the pure literal file and the one hidden behind a factory declaration in a mixed file — so a zero from it is a reading, not an empty sweep. **Reach, stated:** the population is `**/*.object.ts`, which is the ruled population — the `object` row of `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec/src/kernel/metadata-plugin.zod.ts:725`) declares `filePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json']`. Blind to: declarations in files not carrying that suffix, the `.yml`/`.json` patterns (not TypeScript, the factory does not apply), and declarations assembled at runtime rather than written as a literal. Cross-checks run against the whole tree and reported separately: zero `export default` and zero `satisfies` occurrences in any of the 112 files outside comments and strings; zero indented object-literal bindings. **So: zero to convert — and that is a different answer from "the gate saw none".** ## The rule can fail — ablation The detector was neutered on the committed tree and the mixed control re-run. `scripts/ablation-replace.mjs` carried the mutation, so the anchor hit and the blob move are its own verdict rather than a remembered claim: ``` ablation-replace: anchor "literalShapeDeclarations(struct, masked).map" x1 (before) ablation-replace: ok mutation landed: anchor 1 -> 0, blob bce8e23 -> 71718fb1874b with the detector present: GATE EXIT=2 the gate NAMED ctrlHidden with the detector ablated: GATE EXIT=0 ctrlHidden UNNAMED, silently unprotected ablation-replace: ok restored: blob == HEAD (bce8e23) and `git diff HEAD` is empty ``` The ablation also found a defect in the first draft of this change: the ablated run still printed `Authoring shape: 0 literal-shaped declarations ... every declaration is ObjectSchema.create`, over a tree holding an unbounded keyed text column judged by nothing. That zero has no floor under it, so a dead detector printed the identical line. The pass line now reports what was **scanned** and names `--self-test` as the liveness proof (commit 2). A new `--self-test` battery, `the authoring shape: the factory is the one authorised declaration`, registers 15 cases and is pinned at 15 — its true registered count, measured at this head — and `SELF_TEST_BATTERY_FLOOR` moves 9 to 10. Pinning it below its count would have reproduced this PR's own defect class one level up, in the ratchet: at a pin of 12 any three cases could be deleted with the floor still green, including both MIXED cases, which are the only ones that exercise the hole the rule closes. Every other battery in the roster pins at exactly its registered count (3/15/8/11/10/5/7/4/4), measured in one pass by over-pinning each entry to a sentinel and reading the floor's own `registered N case(s)` line — a RUNTIME count, because one battery registers through a loop and a literal source count is not a general method. The regression case is `MIXED`: a whole-file fixture alone would pass identically with the detector deleted, because the whole-file case was already refused by the old backstop. ## Changeset — measured, and it is owed by the other half **This PR publishes nothing.** `scripts/check-keyed-text-bounds.mjs` sits inside no workspace package directory (checked against every tracked `*/package.json` directory), and npm packs relative to the package directory, so it cannot be packed. The repo-root manifest is `private: true`. No non-private package lists a `scripts` directory in `files[]`. Positive control: `packages/spec` is non-private and ships `["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]` — a real `files[]` exists and does not reach repo-root `scripts/`. The new symbol `literalShapeDeclarations` occurs in exactly one file, that one. `skip-changeset` was applied **by the seat**, with its own measurement recorded at comment 5775287713 (route 1: the one changed file is repo-root `scripts/`, inside no package directory; root manifest `private: true`; 77 manifests censused for `files[]` escape hatches, zero found, with a lit control). The stale `Check Changeset` red was re-run in that same act — ⛔ not a flake re-run: the gate's input changed after it ran. Worth the seat's attention: the ruling asks the changeset to state the one mechanical user rewrite (wrap the literal). The rewrite is something a **user** experiences, and the change a user experiences is item 1 — `os init` / `os g` emitting the factory shape from a published package. So the ruling's changeset obligation attaches to the half that publishes, i.e. the `domain:cli` sibling PR, not to this one. ## What item 1 actually requires — real files The seat files the `domain:cli` card from this list. Measured, not guessed: **Emitters (behaviour change):** - `packages/cli/src/commands/init.ts:649` and `:744` — the two `TEMPLATES` entries for `src/objects/__name___item.object.ts`, both emitting `const ${toCamelCase(namespace)}Item: Data.ServiceObject = {`. - `packages/cli/src/commands/generate.ts:99` — `os generate object` emits `const ${toCamelCase(name)}: Data.ServiceObject = {`. **The ruling's item 1 does not name this file, and it must.** Its own docblock declares the coupling: "objectstack-ai#9666 took it once for the `os init` templates, and this emits the SAME value with the same explanation, so the two doors an author can arrive through agree. If that template's value ever moves, this one moves with it." **Pins that move with them:** - `packages/cli/test/generate-refuses-unparseable-name.test.ts:255` — `expect(scaffold).toContain('const orderLine: Data.ServiceObject = {')`. - `packages/cli/test/generate-emission-parses.test.ts:148` — `expect(scaffold.source).toContain('const foo.bar: Data.ServiceObject = {')`, plus its docblock at `:14`. - `packages/cli/test/scaffold-emission-typechecks.test.ts:26` — docblock states "The repair is `Data.ServiceObject`". - `packages/cli/test/init.test.ts:493` — reads the scaffolded `my_app_item.object.ts`; re-check its assertions against the new bytes. - `packages/cli/test/init-template-comments-self-contained.test.ts` — the templates carry a long authored OWD comment block that must survive the rewrite. **Docblock only, no behaviour change:** - `packages/cli/src/utils/emitted-source-parses.ts:14` — the utility itself is shape-agnostic (it asks TypeScript's own parser whether the emitted bytes parse); only its worked example names the literal shape. **Coupled, probably no edit:** `scripts/sync-scaffold-emission-policy.mjs` keeps `create-objectstack`'s bundled template's `pnpm`/`typescript` ranges in lockstep with `packages/cli/src/commands/init.ts` (`POLICY_SOURCE`). It syncs version ranges, not declaration shape — but the sibling should re-run it, because `create-objectstack`'s bundled `note.object.ts` is already the factory shape and the two scaffolders would finally agree. **Docs:** `content/docs/deployment/cli.mdx:1323`. ## Acceptance notes Two further emitters of the outlawed shape exist **outside `packages/cli`**, which the ruling names nowhere and which the lane split therefore routes to neither seat. Both are reported rather than changed: neither is a `*.object.ts` file, so item 3's population does not include them, and both sit in published packages, so converting either would change a published payload and re-open the `Clause-②: no` reading this PR carries. - `packages/services/service-datasource/src/external-datasource-service.ts:931` emits `const ${definition.name}: ServiceObject = {` as the object draft that `os datasource introspect --out objects/x.object.ts` writes into a user's project (ADR-0015). A third scaffolder door, server-side. Pinned at `packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts:138`. - `packages/metadata/src/serializers/typescript-serializer.ts:23` emits `export const metadata: ServiceObject = ${jsonStr};` for the `typescript` metadata format. Noted, not filed: `provenanceLine`'s record still reads `-1/-1/-5/-2/-4` against `MEASURED.ref` `fa5d137ab0`, which is information and not a verdict per that file's own header — no action, and no PR or person is due to touch it. Carrier: none. ## Verification - `node scripts/check-keyed-text-bounds.mjs` :: exit 0 — counts unchanged from base, `112/117/250/592/147`, identical before and after.⚠️ This bare invocation is **NOT** a liveness reading: with the detector ablated it still exits 0 and still prints its pass line. What commit 2 closed is the LIE in that line — it no longer claims every declaration is the factory, only what was scanned — and the CI false green, because `--self-test` and the gate are wired as one pair (`package.json:183`, `.github/workflows/lint.yml:731-732`, inside the required `Lint & Repo Gates` job, all three verified at this head). The paired self-test is the liveness proof; the bare run is not. - `node scripts/check-keyed-text-bounds.mjs --self-test` :: exit 0 — 0 failures, all 15 cases in the battery listed by name in the log. - All 31 gate families derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` were run at `beef275c08` and again in full at `4d034cd2f9`, `--ran` reconciling both as **31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN**, every row carrying its exit code, all 0.⚠️ Those two are readings at EARLIER heads. The round that produced `7adf1e0a6c` reports re-running all 31 there with the same reconciliation, and reports that its first sweep read **5 at exit 3 — PREREQUISITE NOT MET, a missing `node_modules` in a fresh worktree — which were NOT MEASURED and never green until `pnpm install` and a re-run**. That last reading is the delivering round's, ⛔ not re-derived by the seat; CI at this head is the seat's own reading, below. - Lint, as a declared narrowing rather than a repo-wide scan: `eslint --no-inline-config --format json scripts/check-keyed-text-bounds.mjs` :: exit 0, **1 file** linted (count read from the JSON output), 0 errors, 0 warnings. The narrowing excludes nothing: this repo runs one `eslint.config.mjs`, which per its own comment at line 327 "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", measured there with a positive control — so a one-file diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` was NOT MEASURED in that round — `scripts/pm/os-verify-lock.sh` returned `queue-timeout (exit 99)` after 9m00s, never acquiring, holder pid 19251 — neither green nor red. ⭐ That NOT MEASURED is now superseded by the delivering round at `7adf1e0a6c`, which reports `eslint . --no-inline-config --format json` :: exit 0 over **7,005 files, 0 errors, 0 warnings**.⚠️ Attributed to that round; ⛔ not re-derived by the seat. - **CI at the current head `7adf1e0a6c`, read by the SEAT by job conclusions, latest run per check NAME — ⛔ never an aggregate roll-up: 31 names, 23 `success`, 8 `skipped`, 0 `failure`, 0 `in_progress`.** All five required checks `success`: `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Dogfood Regression Gate`, `Governed Surface Queue Guard`. The 8 skips are the paths filter on a diff that touches no package. - The `insideFunction` case now pins the guard it is named for. Ablating the column-0 guard (`m[1].length === 0, false` → `true, false`): at `4d034cd2f9` the self-test was exit 0 / PASS / 0 failures; at `7adf1e0a6c` it is exit 1 / FAIL / 1 failure, the failing case being exactly that one. Both legs restored, proven by an empty `git status --porcelain` and an empty `git diff HEAD` rather than by a step's exit code. No label, assignee, ready-flip, auto-merge or enqueue was performed, per the dispatch. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ectstack-ai#19736) Fixes objectstack-ai#19311 Clause-②: no Why that reading: two `.describe()` strings and one TSDoc block. No key is added, removed or re-typed, no closed set gains a member, no export moves and no refinement changes. `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after. Grade: `patch`, measured below rather than pattern-matched. ## The defect, re-derived on `origin/main` rather than taken on trust `FormSectionSchema` declares two authorable booleans with **no `.describe()` at all**, between neighbours that have one: ``` packages/spec/src/ui/view.zod.ts:3315 collapsible: z.boolean().default(false), packages/spec/src/ui/view.zod.ts:3316 collapsed: z.boolean().default(false), ``` **Lit control, same file, same instrument:** `view.zod.ts:1054` reads `collapsed: z.boolean().default(false).describe('Collapse groups by default (presentation only)')` — a different schema (group-by presentation). So a describe on a key of this name IS visible to the grep, and the zero at `:3315–3316` is a reading, not a blind spot. **Dark control:** `git grep -n "collapsedd"` over the same file → 0 hits, exit 1. Downstream of that silence, the generated page printed two empty Description cells (`content/docs/references/ui/view.mdx:334-335`, and again at `:466-467` and `:482-483`, the three places that page projects this schema). **This is NOT the surface the first increment landed on.** PR objectstack-ai#19699 (merged `fa29803417`) corrected `packages/spec/src/data/object.zod.ts:1220` — the `ObjectFieldGroup` pair, a different schema in a different file, which is why it merged as `Part of`. Both keys of **this** pair are described here, not just `collapsed`: describing one of a silent pair recreates the ambiguity one key over. ## Probe — every sentence measured against the BUILT package Built with `pnpm --filter @objectstack/spec build`, then probed through the module the `exports` map resolves (`require.resolve('@objectstack/spec/ui')` → `packages/spec/dist/ui/index.js`, printed in the same run). ### A. `FormSectionSchema.safeParse`, one section, all four combinations | authored | `collapsible` out | `collapsed` out | | :--- | :--- | :--- | | neither — **CONTROL (lit)**: default arm must fire | `false` | `false` | | `collapsible: true` | `true` | `false` | | `collapsible: false` | `false` | `false` | | `collapsed: true` | `false` | `true` | | `collapsed: false` | `false` | `false` | | both `true` | `true` | `true` | | `collapsed: true` + `collapsible: false` | `false` | `true` | | `collapsed: false` + `collapsible: true` | `true` | `false` | | both `false` | `false` | `false` | | `collapsable: true` — **CONTROL (dark)** | REFUSED `unrecognized_keys` | — | | `collapsed: 'true'` — **CONTROL (dark)** | REFUSED `invalid_type@collapsed` | — | Both lit and both dark controls answered as predicted, so the nine readings are measurements. ### B. The normalizer question the dispatch asked — answered NO There is no normalizer, no parse wrapper and no fold between these two keys. `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }`, verbatim, with both keys present in the output (`.default(false)`), and `safeParseAsync` returns the same. The section's only `.transform` is `normalizeVisibleWhen`, which touches `visibleOn` and nothing else. ⇒ That is the **precision limit**, and it is stated in the text rather than left implied: the implication `collapsed` ⇒ `collapsible` is a **renderer** rule applied from the declaration, never a parse-time rewrite. The describes say so explicitly, and say that the parsed `collapsible` must never be read as "a disclosure control renders". This is the exact opposite of the `ObjectFieldGroup` pair, where a real parse-time mapping folds the booleans onto the ADR-0085 `collapse` enum — the two surfaces share key names and share nothing else, so the describes name that difference. ### C. The two refusals the describes claim | input | wizard form | beside `group` | | :--- | :--- | :--- | | `collapsed: true` | REFUSED at `sections.0.collapsed` | REFUSED at `collapsed` | | `collapsible: true` | REFUSED at `sections.0.collapsible` | REFUSED at `collapsible` | | `collapsed: false` | ACCEPTED | ACCEPTED | | `collapsible: false` | ACCEPTED | ACCEPTED | | neither — **CONTROL (lit)** | ACCEPTED | ACCEPTED | | `simple` form + `collapsed: true` — **CONTROL (lit)** | ACCEPTED (the refusal is wizard-only) | — | | `group` + `fields` — **CONTROL (dark)** | — | REFUSED at `group` |⚠️ The first run of this block had its two lit controls come out REFUSED. That was a malformed fixture on my side (`FormViewSchema` is the form CONFIG and carries no `name` / `label` / `objectName`), not a reading — so those rows were discarded and the block re-run after the fixture was repaired. The table above is the repaired run. Recorded because the controls are the only reason the first, wrong version did not ship as a finding. ### D. Reverse verification — the gate can see this change After the describe edit and a spec rebuild, `pnpm --filter @objectstack/spec check:generated` exits **1** and names exactly one stale artifact — `content/docs/references/**` — while the other 14 stay green. **Direction observed = turns red, as predicted.** After `gen:schema && gen:docs` the same aggregate exits 0. That is what makes the final green a measurement rather than a silence. ## Downstream consumer, read at the PINNED sha Read at `.objectui-sha` = **`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`** (not objectui's head, which is `0cf2d66`); `git merge-base --is-ancestor` on the pin exits 0, which is self-proving. - `packages/plugin-form/src/ObjectForm.tsx:1560` applies the ruling: `Boolean(section.collapsible) || Boolean(section.collapsed)`, with a block comment that states it is read from the DECLARATION and never from live collapse state, and that letters B (refuse the combination) and C (a dev-only warning) were both refused. The describes follow that, and ⛔ neither adds a refinement nor asks for a lint rule. - `packages/plugin-form/src/DrawerForm.tsx:609` and `:677` still push `collapsible: section.collapsible` **alone** on both drawer arms. That file's own comment declares the gap deliberate and fenced, and says converging it *is* the `collapsed` / `collapsible` decision. **So the contract this PR publishes is the thing that arm was waiting on** — it is a sibling-repo residual, another seat's card, and nothing here touches it. Reported, not ridden along. - In **this** repo nothing consumes the raw pair: `git grep` for a `.collapsible` / `.collapsed` member access across `packages/**` returns only the `ObjectFieldGroup` mapping and test files; `packages/lint/src` has **0** hits for `collapsible`, with a positive control (`form-section-group-unknown`, 2 files) firing on the same command and scope. - All 22 first-party form producers under `packages/spec/src/**/*.form.ts` pair `collapsible: true` with `collapsed: true`, so **zero** in-repo producers exercise the trap. It is an author-facing trap, not a live in-repo defect — which is precisely why the remedy is declaration text. ## Generated artifacts — the four-step sequence, run mechanically `content/docs/references/**` is routed `merge=os-regen`, the class that merges with exit 0 and zero conflict markers while silently dropping one side. 1. `bash scripts/pm/os-regen-merge.sh` — steps 1–3, exit 0. It merged `origin/main` (`e37ea4d060`), took main's side of the generated artifacts main moved, and committed the merge **before** any regeneration. ⛔ No rebase, ⛔ no force-push, ⛔ no `git stash`, ⛔ no hand merge of a routed path. 2. `gen:schema` was run only **after** `MERGE_HEAD` was gone and the tree was clean, so the authorable-surface anchor could not roll back to the old fork point. `packages/spec/authorable-surface.base.json` is byte-identical to `origin/main`. 3. Regenerated `pnpm --filter @objectstack/spec build && gen:schema && gen:docs`. **Exactly one** file moved, and by exactly the rows this one change derives: 6 rows in `content/docs/references/ui/view.mdx` — the pair, three times, because that page projects `FormSectionSchema` three times. `git diff --name-only origin/main -- content/docs/references/` names that file and no other. 4. Inspected the **staged** diff (`git diff --cached`), not the working-tree diff, before committing. ### Survival assertion for the in-flight sibling, PR objectstack-ai#19618 objectstack-ai#19618 (open, draft, head `7cc0ca1b3d`) touches four `content/docs/references/**` pages. It is **unmerged**, so what my regeneration could destroy is main's copy of those pages. Quoted-exact `git grep -F`, counts identical on my tree and `origin/main`: | needle | path | mine | `origin/main` | | :--- | :--- | :--- | :--- | | the pre-objectstack-ai#19618 `tenancy` type spelling carrying `organizationField?` | `api/metadata.mdx` | 1 | 1 | | same | `data/object.mdx` | 1 | 1 | | same | `system/migration.mdx` | 2 | 2 | | objectstack-ai#19618's post-change `tenancy` spelling | all four | 0 | 0 (it has not landed) | | `STAMP-ONLY: column carrying the organization a row is ABOUT` (its deletion target) | `data/object.mdx` | 2 | 2 | | **CONTROL (lit)** `Multi-tenancy configuration for SaaS applications` | all four | 1 / 1 / 2 | fires | | **CONTROL (dark)** a near-miss of the same spelling | all four | 0, exit 1 | discriminates | All four of its reference pages are **byte-identical** to `origin/main` in my tree (`git hash-object` vs `git rev-parse origin/main:PATH`, four matching pairs). Every os-regen-routed path outside `content/docs/references/ui/view.mdx` differs from `origin/main` by **zero** files. Also asserted, because the merge driver swallows implementation bodies and not only index entries: the **previous increment's** body survives — `git grep -cF "true → 'collapsed' (collapsible, starts closed) on its own"` reads 1 in `packages/spec/src/data/object.zod.ts` and 2 in `content/docs/references/data/object.mdx`, identical on my tree and `origin/main`, with a near-miss dark control at 0 / exit 1. ## Changeset — measured, not pattern-matched `npm pack --dry-run --json` in `packages/spec` (exit 0, 2031 files): | file | verdict | | :--- | :--- | | `src/ui/view.zod.ts` — **the edited file** | SHIPS | | `package.json` — **positive control** | SHIPS | | `src/data/object.zod.ts` — **positive control** | SHIPS | | `src/ui/view.test.ts` — **negative control** | does NOT ship | | `scripts/build-docs.ts` — **negative control** | does NOT ship | | `tsconfig.json` — **negative control** | does NOT ship | Scanning the tarball's own file list for the new describe text: **42 shipped files carry it** — 30 under `dist/`, 11 under `json-schema/`, 1 under `src/`. Negative control with a lit leg: the string `are GENERATED — do not hand-edit them` is present in `packages/spec/scripts/lib/generated-output.ts` and in **0** shipped files, because that file does not ship. ⇒ The bytes publish, so `skip-changeset` would be a false declaration. A `patch` changeset is in the diff. ## Acceptance notes — found, ⛔ not fixed here - `packages/spec/src/ui/view.zod.ts:3274` and `:5951` describe the pair in prose and in a doc example without the dependency; neither is falsified by this change and neither is a trap as written (the `:5951` example writes both keys). Noted, not filed. - The `sections` / `groups` rows on the generated page print a truncated inline type (`… collapsible?: boolean; …`) with no Description at all. That is the generator's type-column truncation, not this pair, and it is unchanged here. - `packages/lint` carries no rule on this pair (0 hits, positive control firing). Consistent with the 2026-09-18 ruling, which refused letter C — a dev-only warning — so this is a deliberate absence, ⛔ not a gap to fill. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…phone (objectstack-ai#19763) Fixes objectstack-ai#19679 Clause-②: no One published sentence described `FieldSchema.format` as `Format string (e.g. email, phone)`: two example words, with no field type and no reader named. It shipped verbatim into `packages/spec/json-schema/`, into `dist`, into the published `src/**/*.zod.ts`, and to a customer in `content/docs/references/data/field.mdx`. On an `autonumber` field the same key is the record-number pattern, so following the documented example there minted `email1`. This round fixes the sentence and nothing else. The key is still `z.string().optional()`: nothing is split, narrowed, retired or gated by type, and no consumer is touched. ## What reads the key, measured and stated positively Round 2 rewrote this section. Round 1 listed two readers and a `nothing else` row. The at-tier review found a third reader at the pinned objectui, so this table lists what each measured reader does and makes no claim that the list is complete. | reader | field types | what the value means there | |:--|:--|:--| | `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196`), minted through by `packages/objectql/src/engine.ts` `applyAutonumbers` (`:5051`) and twice by `packages/drivers/driver-sql/src/sql-driver.ts` (`:10639`, `:10744`) | `autonumber` | the record-number **pattern**: canonical `autonumberFormat` first, then this key, then the default `{0000}` | | `lintAutonumberFormats` (`packages/lint/src/lint-autonumber-formats.ts:60-62`), wired into `os lint`, `os compile` and `os validate` through `packages/lint/src/authoring-rules.ts` | `autonumber` | the same pattern, linted at build time for unrecognised `{...}` tokens and bad `{field}` references | | objectui `DateCellRenderer` and `DateTimeCellRenderer` (`packages/fields/src/index.tsx:1127`, `:1176`) at the pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` | `date`, `datetime` | a display **style**, with its own words and a different default per type | | objectui `resolveCellRendererType` (`packages/fields/src/index.tsx:2915-2947`), same pin; called from the grid (`plugin-grid/src/cellRendererResolution.ts:114`), the detail views (`DetailSection.tsx:411`, `DetailView.tsx:1298`, `HeaderHighlight.tsx:137`, `RelatedList.tsx:1282`), and the kanban, gallery, report and dashboard views | textual base types (`text`, `textarea` and the rest of `TEXTUAL_BASE_TYPES`) | a cell-renderer **hint**: a small word set promotes the cell to a richer renderer. Pinned end to end by `packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx`, where `{ type: 'text', format: 'phone' }` renders a `tel:` anchor | | objectui `renderFieldValue` (`packages/plugin-dashboard/src/recordFields.tsx:382-427`), same pin, fed the object field's `format` at `:334` | any | a display **pattern**: a leading currency symbol, a `%`, or any of the letters `Y`, `M`, `D`, `H`, `m`, `s` selects a formatter, and anything else falls through to `resolveCellRendererType` | Run against this branch's build of `@objectstack/spec` (`dist/data/index.mjs`), seq 1: ```text format: 'INV-{0000}' -> INV-0001 format: 'email' -> email1 { autonumberFormat: 'A-{000}', format: 'email' } -> A-001 {} -> 0001 ``` So an author who followed the key's own documentation onto an `autonumber` field got `email1` as a business identifier. It parsed, it stored, and neither the runtime nor the build-time lint reported it: `lintAutonumberFormats` returns no finding for `format: 'email'`, while a `{nope}{000}` control draws `autonumber-references-unknown-field`. **The negatives the description keeps, each with its radius:** - *On any non-`autonumber` field the server does not act on the key.* Radius: this repo's `packages/**` non-test sources at `a8c7f17751`. Instrument: `git grep` over every non-call property read of `format` (107 hits). Triaged, the only field-definition reads are `autonumber-format.ts:199` and `lint-autonumber-formats.ts:62`, both on the autonumber arm. A word census over `packages/drivers` and `packages/objectql/src` finds every field-reading line gated on the autonumber type (`sql-driver.ts:10638`, `:10743`; `engine.ts:5044`). Firing control: the same instrument returns the two known readers. Named blind spots: multi-line destructuring, computed keys, and whole-object forwarding. Corroboration: `driver-sql`'s own `FIELD_KEY_STORAGE_CLASS` classifies `format` as `presentation` (`packages/drivers/driver-sql/src/builtin-column-collision.ts:97`). - *The write-time record validator never reads it.* Radius: `packages/objectql/src/validation/record-validator.ts`. `def.format` has 0 reads, against `def.type` 7 and `def.maxLength` 2 as lit controls. `const t = def.type` is at `:628`, and the email, url and phone checks at `:746`, `:749` and `:752` test `t`. - *The spec checks nothing but that it is a string.* 49 `FieldType` members times 6 values (`email`, `phone`, `url`, `relative`, an arbitrary string, `INV-{0000}`) gives 294 cells, run through both `FieldSchema` and `ObjectSchema`: 0 issues on a `format` path. Firing control: `format: 123` draws 1 issue on each. - *The two named objectui arms fall back silently.* Read at the pin: the date cell hands the word to `formatDate` (`packages/core/src/utils/date-display.ts:198`), which honours `short` and `relative` and otherwise paints its default face. The datetime cell handles `relative`, `short` and `compact` and hands any other word to `formatDateTime`, which paints its default face (`index.tsx:1243-1282`). `resolveCellRendererType` returns the base type's renderer for a word outside its map (`:2943-2946`). None of the three warns or throws. **Where a value check lives**, which the description now states: the record validator's email, url and phone checks key on the field **type** (`type: 'email'`), and the closed `email | url | phone | json` vocabulary belongs to a **`format` validation rule** (`FormatValidationSchema`, `packages/spec/src/data/validation.zod.ts:204`, its `format` key at `:214`). The description no longer says `email`, `url` and `phone` are "not formats". On a plain-text field objectui reads them as renderer hints, and whether that reading should become a declared vocabulary is the decision this card leaves open. ## Write surface `packages/spec/src/data/field.zod.ts` — the one `describe` string. Everything else in the diff is the repo's own generators: `pnpm --filter @objectstack/spec gen:docs` rewrote `content/docs/references/data/field.mdx`, `data/object.mdx` and `system/migration.mdx` (three projections of one string). Nothing was hand-edited under `content/docs/references/`. **The conditional fence did not trip.** `packages/spec/authorable-surface/data.json` is held by open PR objectstack-ai#19618. `check:authorable-surface` ran green across the edit and the file did not move — a description is not an authorable key — so no hunk under another claim was touched. ## Changeset — measured, not assumed `patch` on `@objectstack/spec`. `skip-changeset` would have been wrong, and the measurement is the reason rather than a rule of thumb: - `packages/spec/src/data/field.zod.ts` is itself a published file — it matches `src/**/*.zod.ts` in this package's `files[]`. - Greping a distinctive fragment of the new text (`record-number PATTERN`) over each `files[]` entry: **22** `dist/` files and **13** `json-schema/` files carry it. - Positive control from the same source — `required`'s existing published describe (`Write-time contract (ADR-0113)`) — returns **22** and **13** over the same two trees. Same counts, so the route is measuring what it claims to measure. ## Is a regression test owed? No, and here is the reasoning rather than a silence - **A negative pin is impossible here.** The obvious pin — assert the description never says `email` or `phone` — goes red on the *correction*, because the new sentence deliberately names both words in order to redirect the author to the field `type` and the validation rule. The defect was a false sentence, not the presence of two words. - **A positive pin on this prose would rot by design.** The next honest sharpening of the sentence breaks it, so the next author edits or deletes the pin — a check nobody trusts is worse than none, and this lane's rule prefers deleting the construct that permits the error over adding a check. - **The construct that permits the error is out of scope this round.** It is `z.string()` with no declared vocabulary and no type gating — narrowing it is exactly the contract-shape decision triage reserved. - **Every behaviour claim the new sentence makes is pinned, but not all of it in this repository.** The autonumber claims are pinned here by `packages/spec/src/data/autonumber-format.test.ts`: canonical-over-shorthand precedence, the `{0000}` default rendering as `0001`, and the no-slot branch. The two objectui arms are pinned in objectui at the pin: `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` for the date and datetime styles, and `packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx` for the plain-text hint. The description names those arms without copying their words, so an objectui change to a word list cannot make the spec sentence false. An objectui change that removes an arm could, and nothing in this repository would go red. That residual risk is why the arms are introduced as examples. - What *would* earn its place is a gate asserting that every example value a `describe` offers is honoured by some reader. Nothing like it exists, building it is well outside a one-sentence repair, and it belongs with the decision below. ## Verification Final commit `a8c7f17751`. No merge of `origin/main` this round: `origin/main` is one commit ahead (`ed4b655e5b`, `scripts/pm/**` only) and touches none of this PR's five files, so `os-regen-merge.sh` was not needed. The branch still carries round 1's merge of `dc1b98680b`. | check | result at `a8c7f17751` | |:--|:--| | `pnpm --filter @objectstack/spec build` (under `scripts/pm/os-verify-lock.sh`) | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/spec typecheck`, then `test`, in one locked run | `VERDICT command-exit 0`; 516 test files, 15056 tests passed, 1 todo | | `gen:schema`, then `gen:docs` | rewrote exactly `data/field.mdx` (1 row), `data/object.mdx` (2 rows) and `system/migration.mdx` (2 rows) | | `pnpm --filter @objectstack/spec check:docs` | exit 0: `225 generated files in sync with packages/spec` | | `pnpm --filter @objectstack/spec check:generated` | exit 0: `All 15 generated artifacts are up to date` | | sentence census | the new sentence appears in `field.mdx` 1 time, `object.mdx` 2 times and `migration.mdx` 2 times; the round-1 sentence and the original sentence appear 0 times in each. `git diff dc1b986 HEAD -- content/docs/references` is the five `format` rows and nothing else | | every family `scripts/pm/dispatch-gates.mjs` derives for this path set, run from its own `--commands` list with each exit recorded | `--ran`: 101 derived, 98 run at exit 0, 3 NOT MEASURED, 0 unrun | | eslint, narrowed | 1 file linted (`field.zod.ts`): 0 errors, 0 warnings, read from `--format json`. The `.mdx` pages and the `.changeset/*.md` answer `File ignored because no matching configuration was supplied`. `eslint.config.mjs:327-329` records that type-aware linting is enabled for no file, so this diff cannot move the verdict on an untouched file | **NOT MEASURED, declared to CI:** `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 (`PREREQUISITE NOT MET`). Each reads built output of packages this diff does not touch: `client-react`, the whole workspace, and `objectql`. Not a pass and not a finding. **The red at `66cb68d947` was an intermediate head.** That push carried the new `describe` before its regenerated pages. `875c198d2c` added them, but its run was superseded by the next push before the docs-sync step ran (`Type Check · source gates` cancelled, step 26 `Check generated reference docs are in sync with the spec` skipped), so that head is **not measured**. At the current head `a8c7f17751` the same step reads `success`. ## Acceptance notes **1. The key-vocabulary decision card triage said was owed does not exist yet.** Triage graded this card "scoped to the `describe`" and said the wider problem "belongs in the decision box as its own card" — nothing has been filed, and this round deliberately does not file it. The readings below are recorded so whoever files it starts from measurements instead of from scratch. The shape of the decision: **one `z.string()` key is read by at least four readers with four different vocabularies (the autonumber pattern, the date and datetime style, the textual renderer hint, and the dashboard display pattern), with no declared value set and nothing that checks the readers agree.** - **Autonumber arm.** `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196`); `AutonumberFormatSource` declares `format` as the shorthand predating `autonumberFormat`; call sites `packages/objectql/src/engine.ts` `applyAutonumbers` and `packages/drivers/driver-sql/src/sql-driver.ts` (two). A pattern-less value renders as literal text plus the bare counter — measured `email1` above — and nothing refuses it. - **Display-style arm** (objectui, pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2`): `packages/fields/src/index.tsx:1127` `const style = dateField.format || 'relative';` for the `date` cell, and `:1176` `const authoredFormat = (field as DateTimeFieldMetadata | undefined)?.format || 'compact';` for the `datetime` cell. Two different defaults for one key. The vocabularies differ too: `formatDate` (`packages/core/src/utils/date-display.ts`) honours `short` and `relative` and silently paints its default locale face for anything else, while the datetime cell maps `relative` and `short` onto its own faces and falls through for everything else, `compact` and date patterns such as `YYYY-MM-DD` included. - **Renderer-hint and display-pattern arms** (objectui, same pin), found by round 2's at-tier review and census. `resolveCellRendererType` promotes a textual field by a word set (`packages/fields/src/index.tsx:2915-2946`), and `plugin-dashboard`'s `renderFieldValue` reads the same key as a display pattern (`recordFields.tsx:382-427`). The two disagree on at least one word. `format: 'email'` on a `text` field is a `mailto:` hint to the resolver, but the dashboard's pattern test matches its `m` and hands the value to `formatDate`, which answers an em dash. That defect is reported separately in the round-2 report. - **Blast radius today is documentation, not data.** No field anywhere in this repository authors `format` — measured over `packages/*/src/objects` and `examples/*/src/data`.⚠️ The published `skills/` corpus is different: it teaches the key on the autonumber arm. `skills/objectstack-data/rules/field-types.md` spells `{ type: 'autonumber', format: 'CASE-{0000}' }` under its Autonumber heading and uses `format:` in both `order_no` examples. That is the shorthand this describe now tells a new field to replace with `autonumberFormat`, so the skill and the describe disagree on which spelling to teach. That disagreement is input for the key-vocabulary decision below, ⛔ not a change made here. What the false sentence reached was the reference docs and the JSON Schema, i.e. exactly what an AI authoring agent reads before writing a field. - **The options the card will have to weigh** are all surface-moving and all reserved: split the key (add a `displayFormat`, or promote the style arm), retire one arm under ADR-0049 enforce-or-remove, narrow the type to a declared vocabulary, or declare the two-arm shape and make each arm refuse the other's words loudly. Each widens or removes a published surface. - Dedupe words for whoever files it: `format`, `autonumberFormat`, `displayFormat`, `field vocabulary`, `date display style`. **2. `packages/spec/liveness/field.json`'s `format` row understates the key.** It is a bare `{ "status": "live", "evidence": "packages/objectql/src/engine.ts" }` — no `verifiedAt`, no function anchor and no note, and it records only the autonumber reader. Its own siblings are richer on exactly the two axes it is missing: `autonumberFormat` names the consuming function, and `rows` carries `evidenceScope: "cross-repo"` for a key objectui reads. The objectui display-style arm is invisible in this row. Not touched here — a ledger row is not the `describe` this card scopes — and it is work the decision card above has to redo anyway, so it is recorded rather than filed. **The same false meaning on six hand-written doc rows — filed as objectstack-ai#19764, ⛔ not fixed here.** This PR repairs the key's own `describe`. Six hand-written rows in `content/docs/data-modeling/field-types.mdx` (`:24` under `text`, `:71` under `phone`) and `content/docs/data-modeling/validation-rules.mdx` (`:46` `text`, `:64` `email`, `:72` `url`, `:80` `phone`) credit `format` with validation it never performs. The reader, measured at source: `packages/objectql/src/validation/record-validator.ts:628` binds `const t = def.type`, and the three shape checks at `:746` / `:749` / `:752` are `t === 'email'` / `'url'` / `'phone'`; that file reads `def.format` **0** times against a same-file `def.type` lit control of **7**. ⇒ on **validation**, this PR's `describe`, which sends an author to `type` and to the `format` validation rule, is the correct side. On display it is not the whole story: the `text` row (`field-types.mdx:24`) describes a live renderer hint at the pinned objectui (see the reader table), which the seat recorded on objectstack-ai#19764. Three of them also declare a `Default` of `email` / `url` / `phone` that does not exist: `FieldSchema.parse({ name: 'x', type: 'email' })` returns no `format` key at all. Out of this PR's scope — triage scoped the card to the `describe`, and hand-written docs are outside its claimed file surface — so they ride their own card, where the routing question (hand-written `content/docs/**` versus the `FieldSchema` surface it describes) is triage's. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e, applied nowhere (objectstack-ai#19657) Fixes objectstack-ai#19580 `Clause-②: yes (narrowing)` Ruled: comment `5770606746`, batch objectstack-ai#211 item 1, **letter A** — retire `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; the standing 2026-09-10 「以协议为准」 ruling; the 2026-08-27 「no staged retirement」). Removal route: the `spec-property-retirement` playbook. ## What this removes A **fully authorable** key — bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d, writable on `ConnectorSchema` and, through `DeclarativeConnectorEntrySchema`, on `stack.connectors[]` and `PUT /meta/connector/:name`, and served back by `/meta/connector`. Every signal an authoring surface can give said it worked. Plus the `ConnectorProviderContext.connectionTimeoutMs` member handed to every provider factory. `requestTimeoutMs` is the replacement: the deadline the platform actually keeps, applied as `resilientFetch`'s per-attempt timeout. ## ⭐ The measurement the ruling left to the dev — D2 or D3 The ruling prescribed a **D3 semantic** entry and said a **D2 conversion** is owed **only if a stored connector row can carry the key** — 「the dev measures」. It can, so **both** ship. Measured first-hand on this branch, before the tombstone landed, against the built `packages/spec/dist`: | leg | reading | | --- | --- | | `getMetadataTypeSchema('connector')` bound? | `true` — it resolves `DeclarativeConnectorEntrySchema`, the shape `PUT /api/v1/meta/connector/:name` validates against | | door parses a body carrying the key? | `true` | | does its **output retain** the authored value? | **`4321`** — so the number reaches `sys_metadata` | | control on the same instrument | the already-retired sibling `errorMapping` on the same schema is **refused** — the door discriminates rather than accepting everything | | `applyConversionsToStoredItem('connector', row)` live for this type? | **yes** — it fired `connector-error-mapping-removed` and stripped that key from a stored row | | control on the same seam | the same row's `requestTimeoutMs` survived untouched, so the strip is attributable | **What would have made it the other answer:** `getMetadataTypeSchema('connector')` returning `undefined` (no write door ⇒ no stored row), or the door's output dropping the key, or the rehydration seam never reaching `connector` rows. All three fired the other way, so a D2 conversion is owed and a D3-only kit would have left 17.x rows carrying a key the schema now refuses. Both dispositions are re-measured by pins in `packages/spec/src/integration/connector-connection-timeout-retirement.test.ts`, so the answer cannot rot into an assumption. ##⚠️ The card is STALE, not wrong — and this section previously said otherwise ⛔ This section's earlier premise is known false and is replaced rather than patched. It claimed the card's Leg-2 table 「was already false when this retirement was taken」. It was not. **At the SHA the card cites and dates — `0870fb5418`** — `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` returns **exactly five** non-spec source hits, and **all five are `connectionTimeoutMs: 30000,`**: the card's table, line for line. ⇒ the card was **correct when measured**. What moved it is `b929e0a662` (objectstack-ai#19388) — the very PR the card itself flagged as pending. **At `origin/main`** the same instrument returns **thirteen** non-test source occurrences over **seven files** in **five packages**: six reads, four type declarations, and three surviving pure hardcoded `30000` writes (`connector-mcp/src/mcp-connector.ts`, `connector-slack/src/slack-connector.ts`, `service-automation/src/plugin.ts`).⚠️ Seven files, not five — five is the count of *packages*, and conflating the two is how the earlier number was reached. | site | what it is | | --- | --- | | `services/service-automation/src/plugin.ts:307` | reads `entry.connectionTimeoutMs` into the **materialization fingerprint** | | `services/service-automation/src/plugin.ts:1589` | reads it onto `ConnectorProviderContext` | | `connectors/connector-rest/src/rest-provider.ts:64` | reads `ctx.connectionTimeoutMs` | | `connectors/connector-openapi/src/openapi-provider.ts:193` | reads `ctx.connectionTimeoutMs` | | `connector-rest/src/rest-connector.ts:134`, `connector-openapi/src/openapi-connector.ts:242` | `?? 30000` — read the opts and deposit the value on the reported def | **The ruling's premise survives, and the mechanism is unchanged.** Every read is a **pass-through**. The value's only termini are (a) the def `GET /connectors` echoes and (b) the fingerprint that decides whether to re-materialize. `connectorFetchOptions()` (`integration/connector-fetch-policy.ts`) is handed `{ retryConfig, requestTimeoutMs }` only, and a pin has asserted since objectstack-ai#18975 that nothing aliases this key onto `timeoutMs`. **Carrying a number is not honouring it** — the parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. ## Zero-enforcement verification, with its control - **Claim:** nothing applies the value as a deadline. **Instrument:** `git grep -n connectionTimeoutMs` over the whole worktree (45 hits, hand-read, not counted) plus the source of `connectorFetchOptions()`. **Radius:** the monorepo. **Control:** `requestTimeoutMs` — same schema, same census, same files — resolves to a real read (`opts.timeoutMs = policy.requestTimeoutMs`), so the instrument is not dead. - **Pinned sibling checkout:** `git grep connectionTimeoutMs` at objectui `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` (the `.objectui-sha` pin) → **exit 1, zero hits**; control `connector` on the same command and scope returns **458 lines across 66 files**; and `requestTimeoutMs` is exit 1 / 0 lines there, so it is **not** a usable control in that repo (it is in objectstack). ⇒ the `Console Pin Gate` needs no sibling fix and no pin bump with this removal.⚠️ **That clearance is about the sibling BUILD, and it is not the whole picture.** The residue stage makes both carriers `z.preprocess` pipes, and objectui's `packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts:468` asserts `checks(DeclarativeConnectorEntrySchema) > 0` — **1** against `main`, **0** here, because a pipe def has no `checks` array. The SPA still builds and `Console Pin Gate` never runs that suite, so no gate in either repo sees it. objectui resolves `@objectstack/spec` from the registry at `^17.0.0`, so ⛔ `main` does not go red on merge — the break lands at objectui's next spec bump. Tracked at **objectui#10211**; the gate-reach gap at **objectstack#19692**. ⛔ A `.objectui-sha` bump is never a rider on another PR, so neither rides here. - **tsc is the real sweeper.** `retiredKey()` types the key `never`, so every authoring site in the monorepo fails to compile. All six affected packages typecheck green after the cleanup, which is what says the census is complete rather than the grep. ## The retirement kit - `retiredKey()` tombstone on the non-strict `ConnectorSchema` (a bare delete would be a silent strip, ADR-0104), inherited by `DeclarativeConnectorEntrySchema`. - `RETIRED_KEYS_BY_MAJOR[18]` × 2 — `integration/Connector:connectionTimeoutMs` and `integration/DeclarativeConnectorEntry:connectionTimeoutMs` — as one-file-per-entry under `migrations/entries/retired-keys/`. - **D2** `connector-connection-timeout-ms-removed` in `conversions/registry.ts`, wired into the step-18 chain. - ⭐ **ADR-0087 residue stage** `acceptRetiredDefaultResidue` on **both** carriers with `{ connectionTimeoutMs: 30000 }`. A D2 does **not** discharge this: the ruled precedent `18.security__ObjectPermission__allowPurge` carries **both** a D2 (`permission-allow-restore-purge-removed`) **and** the residue stage, so D2 coverage cannot be the discriminator. The discriminator is whether a released toolchain MATERIALIZED the default — a 17.x toolchain emits `connectionTimeoutMs: 30000` into every connector entry, authored or not — and the second door is `AutomationEngine.registerConnector`, which parses `ConnectorSchema` for a def a plugin builds **in code**, where no conversion runs. Without the stage a 17.x connector package fails registration on a value its author never typed. Head now accepts-and-strips `30000` while still refusing `15000`, `1000` and `"30000"`. The preprocess pipe this introduces moves **five** ADR-0097 refinement sites onto its OUT side, so `dropped-refinements.baseline.json` moves with them — exactly the moves the build gate printed, no additions. - **D3 semantic** `connector-provider-context-connection-timeout-ms-retired` for the withdrawn `ConnectorProviderContext` member — a provider factory is code, so there is no authored source for a conversion to rewrite. - `liveness/connector.json`: the row **stays** `dead` with a `REMOVED` note, because `retiredKey()` keeps the key in the walked shape (the `rls.priority` precedent). Its stale 「every occurrence outside `packages/spec` is a WRITE」 claim is corrected there, with the reads named. - Baselines: `authorable-surface/integration.json` gains two `[RETIRED]` rows, `authorable-defaults/integration.json` loses the two `= 30000` rows. `api-surface/` and `json-schema.manifest/` are **byte-identical** — the correct reading for a key-only tombstone that retires no def, not a missed regeneration. - Consumers cleaned: the four connector packages and `service-automation` (fingerprint, declared-item shape, context build, degraded husk). - **Declared widening, round 4:** `packages/spec/liveness/README.md`'s `connector` row asserted, present tense, that the entry schema **is** `ConnectorSchema.superRefine(...)` — and rested its byte-identical-key-set conclusion **on** that attachment. Both halves are corrected: the mechanism is now the pipe's read-through `shape`, and the conclusion is re-measured rather than inherited (30 keys each carrier, byte-identical, zero entry-only, zero base-only).⚠️ Hand-edited on purpose, ⛔ never regenerated: `.gitattributes:71-77` splits `liveness/state-counts.md` (driver-managed numbers) from `liveness/README.md` (hand-written Notes prose), because 「regenerating a Note would fabricate a verdict」. -⚠️ **A SECOND, declared narrowing: the `ZodObject` combinators leave both published exports.** Wrapping `ConnectorSchema` and `DeclarativeConnectorEntrySchema` in the residue stage makes them `z.preprocess` **pipes**, so `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()` and `.safeExtend()` no longer exist on them. Build on the object and re-wrap — `acceptRetiredDefaultResidue(<the extended object>, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route.⚠️ `.superRefine()` still **exists** on a pipe and is callable, but returns a schema with **no read-through `shape`** — which is exactly what the schema walkers duck-test — so refine before wrapping, never after. Parsing, `z.input` / `z.infer` and the read-through `.shape` are unchanged. The changeset's FROM → TO carries this row; the docblock at `connector.zod.ts` and the superseded sentence it replaces carry it in the source. - Changeset `Clause-②: yes (narrowing)`, `minor` on `@objectstack/spec` (the launch-window gate refuses `major`), `patch` on the five consumer packages, with the FROM → TO table and the ADR-0087 disposition marker. ## Tests and gates run locally | run | verdict | | --- | --- | | `pnpm --filter @objectstack/spec build` | exit 0 | | dependency-closure build of the five consumer packages | exit 0 | | `typecheck` × 6 (`spec`, `connector-rest`, `connector-openapi`, `connector-mcp`, `connector-slack`, `service-automation`) | exit 0 | | `test` × 5 consumer packages | 1786 passed | | spec `src/integration src/conversions src/migrations` + the migrate-sentence and cron pins | 569 passed / 16 files | | spec `test:repo` | **600 passed** | | full `pnpm build` + the re-derived 112-command gate sweep on this head | **112 / 112**, and on the latest round with **zero** prerequisite failures, because the full build ran first (earlier rounds had three first-pass non-zeros, all `PREREQUISITE NOT MET` from unbuilt packages ⇒ read as NOT MEASURED, ⛔ never as failures, and re-run green after the build) | | the new retirement pin under `--project repo` | **15 passed** | | `check:generated` | **15 of 15** green, `check:docs` and `check:liveness` included | | the 14 source audits `check:generated` names as not run | all exit 0, each captured separately | | `check:nul-bytes`, `check:cross-package-test-inputs`, `check:adr-0087-registration`, `check:changeset-no-major` | exit 0 | Every exit code above was captured before any pipe. The repo-wide gate farm is CI's run, not this PR's local obligation. ## Acceptance notes - **Scope deviation, declared.** The dispatch fenced `content/docs/**` off. `content/docs/references/integration/connector.mdx` is an **auto-generated** baseline whose gate (`check:docs`) is inside the required `TypeScript Type Check` job, and it goes stale on this change alone. Measured across all 19 open PRs (283 file rows, 0 unreadable): **zero** hold that path, so the fence's stated reason — "open PRs hold files there" — does not apply to it; the instrument discriminates, returning `content/docs` rows for seven other PRs. It is regenerated here, exactly as the sibling retirement objectstack-ai#19618 regenerates four of the same tree's pages. No hand-written `content/docs/**` prose is touched, and `skills/**` and `.claude/**` are untouched — this diff hits **no governed surface**. - **Scope, mechanically forced.** The tombstone types the key `never`, so the four connector packages and `service-automation` must stop writing it or the monorepo does not compile. Those paths are outside the claim's declared file surface and are held by **zero** open PRs on the same census. - **The two-writer surface materialised as declared** — see the report. - `packages/spec/vitest.repo-tests.json` gains one line: the new tree-scoped absence pin's walk radius, which `check:cross-package-test-inputs` demanded by name. No new glob; the radius was already declared for this package. - The `connectionTimeoutMs`-is-never-mapped pin in `connector-fetch-policy.test.ts` is **kept** after the retirement, deliberately: it is what makes a re-introduction as a silent alias onto `timeoutMs` fail. - `health.circuitBreaker` remains `dead` on this schema and is **not** touched here — a different set of rows on the same ADR-0049 worklist. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ing its at-tier review (objectstack-ai#19993) Fixes objectstack-ai#19973 Clause-②: no ## 维护者速读(草稿) **改了什么**:把 `needs:contract-review` 恢复为「PR 在等达档契约复核」的可见标记。规则落在 `contract-review.md` 新增的一节(何时挂、何时摘、谁读,并写明没有任何检查读取它);`landing-operations.md` 里「子代理起不来」那一行原地改写,点名这个标记(行数不变);`ensure-pm-labels.sh` 加回这个标签的定义(新颜色,说明写明「只是标记,没有检查读它」)。 **为什么改**:您的原话「恢复 needs:contract-review,把这句原话写进一张 skills 车道的卡」,回答的是「只作等复核标记,不作闸门」那一问。上一班达档复核子代理连续被限流,12 个 CI 全绿的 PR 在等达档复核记录,只能靠翻座位贴才看得到;现在一个过滤 `is:pr is:open label:needs:contract-review` 就能列出来,交接也不必先读座位贴。 **风险与代价(含回滚)**:不新增任何门禁:没有 check、workflow、队列守卫或巡查脚本读它,落地仍只认 `## Contract review` 记录,标记丢了或多挂了都不会放行或拦下任何 PR。代价是派发席每个条款② PR 多两次标签写(ACCEPT 时挂,PASS 时摘)。已逐个核对本仓会写 PR 标签的 workflow:今天没有任何自动机制会摘掉手挂的 PR 标签。回滚 = revert 本 PR;GitHub 上的标签对象不受影响。 **席位意见**:(留空,待席位填写) **你要做的**:合并后请持有 `gh` 的人跑一次 `bash scripts/pm/ensure-pm-labels.sh --reconcile`,把现存标签对象的颜色与说明对齐(它现在是 GitHub 自动建的灰色、说明为空)。本 PR 的受管路径全在 `.claude/**`(Tier S),由席位在达档复核 PASS 后落地,不等您点合并。 ## Summary The maintainer's instruction recorded on objectstack-ai#19973 — 「恢复 needs:contract-review,把这句原话写进一张 skills 车道的卡」, answering a question that proposed 「只作等复核标记,不作闸门」 — brings `needs:contract-review` back as a **visibility marker, never a gate**. Every layer that ruling record 5770886272 (letter B) retired stays retired: no queue-guard refusal, no `--pair`, no double carrier, no independence pair. The enqueue gate still decides on the `## Contract review` record alone, and nothing in this diff reads the label. ## What changed — 3 files, +28 / -2 | path | change | |---|---| | `.claude/skills/pm-dispatch/references/contract-review.md` | a new section, heading plus 5 rule lines (:30-:36); the :3 pointer now lists it. 28 → 36 lines, ceiling 60 | | `.claude/skills/pm-dispatch/references/landing-operations.md` | :13 rewritten in place to name the marker. 101 / 101 lines; that line goes 113 → 119 bytes | | `scripts/pm/ensure-pm-labels.sh` | one main-repo row after `needs:pack-smoke`: colour `bfdadc`, a 95-character `-d`, and a comment block naming the label's readers | The rule as landed (contract-review.md :32-:36): - it is only a marker, not a gate. The PR is the single carrier; a copy on the card is outside the rule and not required. - **hang**: at ACCEPT, if either clause-② limb hits and no same-form PASS is on file for the current head, the dispatching seat hangs it on the PR in the same stroke. - **clear**: when a same-form PASS is on file for the current head, the seat that posts it clears the marker in the same stroke. When the PR merges or closes, the dispatching seat clears it. **A FAIL does not clear it.** - **readers**: the maintainer's filter `is:pr is:open label:needs:contract-review`, and each seat's patrol and handover. - ⛔ no check, workflow, queue guard or patrol script reads it. Enqueue recognises only the same-form record, and the marker being present or absent changes no verdict. landing-operations.md :13, before and after: ```text - 子代理起不来 ⇒ 复核缺席,PR 留 draft 队列外等档;唯一旁路是维护者亲审,逐次为准。 - 子代理起不来 ⇒ 复核缺席,PR 带 `needs:contract-review` 留 draft 队列外;旁路仅维护者逐次亲审 ``` The line keeps three facts: the review is absent; the PR stays draft, outside the queue; and the maintainer's own review is the only bypass, per instance (唯一 … 逐次为准 → 仅 … 逐次). 「等档」 is carried by the marker itself, which already says the PR awaits its at-tier review. Keeping 「等档」 as well measured 125 bytes, over the 120-byte cap. ## Durability — what removes a PR label on this repo today (read at base `ba77509eee`) - `pr-automation.yml` job `pr-size` → `scripts/pr-labels.mjs --size`. It POSTs the computed `size/*` label, then sends a targeted DELETE only for stale `size/*` labels (`planSizeWrites` loops over the size family and nothing else). The job is skipped on `labeled` / `unlabeled` / `edited`. - `pr-automation.yml` job `auto-label` → `scripts/pr-labels.mjs --paths`. It only POSTs: 「Path labels are ADD-ONLY … So this half issues POST and has no DELETE at all」 (:225-:227). The keys in `.github/labeler.yml` are documentation, `protocol:*`, ci/cd, dependencies, tests and tooling; none is a `needs:*` label. - `lint.yml` runs `node scripts/pr-labels.mjs --self-test`, which pins that no write plan emits a PUT. It also runs `node scripts/check-whole-set-label-write.mjs`, which reds on a whole-set `PUT /issues/{n}/labels` in any spelling anywhere in the repo. That verb (third-party labelers, and a `labels` field written through MCP) is what removed this label in the gate era. - `stale.yml` (`actions/stale`) removes only its own `stale` label. It closes a PR after 37 idle days, and a close is already a clear trigger in the rule. - `half-state-patrol.yml` runs `sweep-closed-cards.mjs --write`, which strips `PM_RESIDUE_LABELS` (the `pm:*` state labels) from **closed cards** only. - `merge-queue-triage.yml` adds labels to its anchor issues only. `fleet-write.yml` runs only the ops a seat names. - objectui's labeler runs with `sync-labels: true`, but that is objectui's. This label is created in this repo only. ⇒ **Today no mechanism on this repo removes a PR label that a seat hung by hand.** The live carriers' event history agrees. Every labeled or unlabeled event for `needs:contract-review` on PR objectstack-ai#19962, PR objectstack-ai#19968, objectstack-ai#19955 and objectstack-ai#19953 is by `objectstack-fleet[bot]`, that is, by a seat. The only removal pair (PR objectstack-ai#19962 at 11:27:07Z, objectstack-ai#19953 at 11:27:41Z) was the spec seat's own stroke after an at-tier FAIL (comment 5813182458, 「Carriers stripped on the PR and on this card」), and both were hung again at 12:14Z. Losing a marker is also the safe failure: a waiting PR drops out of the filter, but nothing is released, because the queue guard reads the record. ## Live carriers at dispatch (read 2026-09-24T14:46Z) — ⛔ this PR changes no label on any of them | carrier | kind | what the rule says | |---|---|---| | objectstack-ai#19962 | PR, draft, head `22c9473c86` | path limb hits (`packages/spec/src/security/rls.zod.ts`). The marker stays until a same-form PASS is on file for its current head; whoever posts that PASS clears it. Under the rule, the 11:27Z clear after the FAIL would not happen: a FAIL leaves the marker on. | | objectstack-ai#19968 | PR, draft, head `b05a88136d` | path limb hits (`packages/spec/src/ui/view.form.ts`). Same as above. | | objectstack-ai#19955 | card | a card copy is outside the rule and not required. What happens to it is for the spec seat that hung it. | | objectstack-ai#19953 | card | same as objectstack-ai#19955. | Aligning these four carriers is the dispatching seat's closeout step once the rule is on `main`, as the claim amendment on the card says. It is not part of this PR. ## Four scripts that still name the label as retired — unchanged, on purpose `scripts/pm/check-half-states.mjs` :11927 and :18308, `scripts/pm/check-skill-line-ratchet.mjs` :448 and :830, `scripts/pm/check-widening-tells.mjs` :673, and `scripts/pm/clause2-line.mjs` :11 and :306. Each one describes the **gate role** (a half-state row that patrolled it, a raise provenance, a dated census line, the ruling's summary, a measured incident). That role is still retired, so every sentence stays true. None of them reads the label, and this PR does not make any of them a reader. `AGENTS.md`, `SKILL.md`, `state-machine.md` and `.claude/agents/os-dev.md` are untouched too; the claim excluded them. ## Acceptance notes - The filer's reading on the card calls objectstack-ai#19955 and objectstack-ai#19953 PRs. The REST objects carry no `pull_request` key, so they are cards; the claim amendment already reads them that way. - 40 cards and PRs that are no longer open still carry the label from the gate era (for example PR objectstack-ai#19666 and PR objectstack-ai#19618; 44 items in all, 4 of them open). The filter reads `is:open` and no script reads the label, so they are inert. Nothing in this PR touches them. - Governed PRs on either landing tier, this one included, also wait on an at-tier `## Contract review` record. They are outside the restored marker's population, which is only the two clause-② limbs, the population the retired label had. Whether to widen it is left to the seat as an open question in the report. - `SKILL.md`'s state-model table does not list the marker. It is a PR label, not a card state; its rule lives in `contract-review.md`; and `SKILL.md` is outside this PR's surface. ## Pending after merge — the seat's, not this PR's - Someone holding `gh` runs `bash scripts/pm/ensure-pm-labels.sh --reconcile` once. The live object is `ededed` with an empty description (read 2026-09-24T14:46Z), and create-if-missing never changes an object that already exists. ## Tests — on `84f4580e` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; three-dot vs merge base `ba77509ee`) derived 32 commands. The dispatch named four more: `node scripts/check-skills-token-ratchet.mjs`, `node scripts/pm/check-governed-queue-guard.mjs --self-test`, `pnpm check:pm-expected-skips` and `pnpm check:pm-governed-prose`. **All 36 exit 0**, each exit code captured before any pipe. `--ran` reconciliation: 「32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN」. - `pnpm check:pm-label-desc-cap`: 「39 label descriptions … all ≤100 characters (longest: 100, tooling)」 (38 on base). - `pnpm check:pm-skill-ratchet`: 「contract-review.md is 36 lines (ceiling 60; headroom 24)」 and 「landing-operations.md is 101 lines (ceiling 101; headroom 0)」. All lines are ≤120 bytes; :3 is at exactly 120. - `pnpm check:pm-skill-id-lint`: 「34 file(s) clean」. `pnpm check:skill-frame-sync`, `pnpm check:doc-authoring`, `pnpm check:pm-governed-prose` and `pnpm check:nul-bytes` are green. - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited **3** (PREREQUISITE NOT MET: `@objectstack/formula` / `@objectstack/lint` not built). That run measured nothing. After `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0), the rerun exited 0. - `bash -n scripts/pm/ensure-pm-labels.sh` exits 0. A fake `gh` on PATH ran `ensure-pm-labels.sh --reconcile`, exit 0: the new row issued `label create needs:contract-review -R objectstack-ai/objectstack -c bfdadc -d …` and the matching `label edit … --color bfdadc --description …` with the same string. - `node scripts/pm/check-governed-merges.mjs --test` on the three paths returns GOVERNED, **Tier S** (`.claude/** ×2`); `scripts/pm/ensure-pm-labels.sh` is not on the register. - A self-scan for control bytes on the three files finds none. - Not run locally: no package is touched, so there is no build closure and no package test or typecheck. `pnpm lint` and the CI-only families (the shard attestation, the test-completeness reader and the type-check lanes) are left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19054
Clause-②: no
Executes the maintainer ruling recorded verbatim on the card: 「organizationField 撤出可授权面 同意你的建议」.
object.tenancy.organizationFieldleaves the authorable surface at protocol 18 (ADR-0049 enforce-or-remove). The divergence the key existed for is not retired — only its authorability.What the key was, and why it could never be more than one table's fact
It answered "which column says who this platform row is ABOUT", where
tenancy.tenantFieldanswers "what is this object WALLED by". The spec's own docblock stated the consequence: "For ordinary objects the two coincide andorganizationFieldis never needed." Re-measured at head before this branch: the entire repository declared it once, onpackages/platform-objects/src/identity/sys-api-key.object.ts— the better-auth credential table — and zero business objects declared it. Its readers were three platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still being authorable on every object.The shape of the change
TenancyConfigSchemais astrictObject, so this is the strict-deletion route:TENANCY_RETIRED_KEY_GUIDANCEgains its prescription beside the two v15.0 precedents (tenancy.strategy,tenancy.crossTenantAccess). Authoring it is now refused with the prescription, not strippedobject-tenancy-organization-field-removed(toMajor: 18,retiredFromLoadPath: true) strips it from authored sources and storedsys_metadatarows; D3 wires it into the protocol-18 chain step;RETIRED_KEYS_BY_MAJOR[18]declaresdata/TenancyConfig:organizationFieldauthorable-surface/data.jsonrow is deleted in this same commit — the strict route's tripwire, with the build computing the guidance-route proof for itselfliveness/README.md'sobjectrow records why, andstate-counts.mdmovesobject51 → 50 liveLimb 0 of the shared resolver now reads a platform-internal table instead of a declaration:
keyed by the object's registered NAME, read by the STAMP face alone.
resolveRecordOrganizationFieldandcreateRecordOrganizationResolverkeep their signatures —check:api-surfaceis byte-identical — and the engine-bound face passes the name it was asked about rather than readingobjectDef.name, because several engine doubles in this monorepo return a bare{ tenancy, fields }map with noname.The two facts the card said must survive
sys_api_keyismanagedBy: 'better-auth', soresolveInjectedSystemColumnsbails before tenancy is consulted and noorganization_idis injected. Pinned, and the pin is now stated as the better-auth bail rather than as key-blindness (packages/spec/src/data/injected-system-columns.test.ts).organization_id. In this platform "has anorganization_idcolumn" IS the wall, so the rename would wall the credential table on an equality that excludes NULL.plugin-security's Layer-0 suite pins both halves against the real shipped object.The stamp/wall divergence pin is green:
resolveRecordWallOrganizationFieldnever read the key and is untouched.Base merge after #19600 landed, and the tombstone version it exposed (2026-09-23)
The collision partner this section used to name, #19610, has landed, and so has #19600 (card #15178, merged at 03:04:25Z as
d0f1845657). #19600 is one of the three PRs in the serial onpackages/spec/src/migrations/registry.tsdescribed in notice5780847968. After it landed, this PR readdirty.registry.tsis only partly generated. Its<os-generated …>regions are regenerated. Butregistry.ts:18-38says outright that each step'srationaleandconversionIdsare hand-written and merge as text. No gate turns red when a paragraph is dropped from them.The merge was done on the branch with no rebase and no force-push. It is three commits:
bde765bf05mergesorigin/mainat67add1301a. It is a merge commit with parents7cc0ca1b3dand67add1301a, and it resolved two textual conflicts by hand.step18.rationalekeeps feat(spec)!: split the translation bundle type —settingsis a platform group, not a per-app one (#15178) #19600's paragraph verbatim. Its last line is re-terminated with a trailing space, and this PR's paragraph is appended after it.step18.conversionIdskeeps both'translation-per-app-settings-removed'and'object-tenancy-organization-field-removed'. That gives 33 ids, 33 of them distinct.packages/spec/src/conversions/registry.tskeeps both D2 conversions inCONVERSIONS_BY_MAJOR[18], in landing order. The file's own rule is 「ordering within a major is application order」.9d5fb0ba5fis regeneration only. It regenerates the two reference pages thatos-regen-merge.shhad deferred.3fb1a4994cis a CONTENT change, not merge resolution. The merge brought incheck:future-spec-major(fix(spec,core): ADR-0049 tombstones name the npm release that carries the removal, and a gate keeps them there #19655), which landed after this PR's old base, and CI went red on two sites. Under ADR-0087 (amended 2026-09-13), a tombstone names the npm release it ships in, never the protocol major. This retirement shipsminor, so it lands in 17.x. The commit therefore changes the prescription atpackages/spec/src/data/object.zod.ts:540and its refusal pin atpackages/spec/src/data/object.test.ts:1947from@objectstack/spec 18to@objectstack/spec 17. The protocol-major references (toMajor: 18,RETIRED_KEYS_BY_MAJOR[18],os migrate meta --from 17) are unchanged, because the gate permits them.Measured by the dispatching seat against the committed trees, not taken from the dev's narration:
67add1301a: 2 files, +128/−1. Every hunk is this PR's.7cc0ca1b3d: 2 files, +656/−28. Every hunk is main's.registry.ts, each of the following appears exactly once:dataset. 'settingsis a platform group, not a per-app one (#15178) #19600's paragraphsettingsis a platform group, not a per-app one (#15178) #19600's last line, continuing with a trailing spaceconversionIdssettingsis a platform group, not a per-app one (#15178) #19600's.5765681233) names head7cc0ca1b3d. Commit3fb1a4994cchanges a string that review's AC2 pinned, so this head move is not regeneration-only, and the earlier record does not govern the new head.check-clause2-carriers.mjs --pair 19618confirms it: exit 4, C6. A fresh contract review of the current head is owed before landing.Verification
Re-measured at the current head
3fb1a4994c, after the base merge.CI, measured by the seat from the head's check-runs: 35 checks, latest run per name. 33 success, 2 skipped (
Console Pin Gate,Packed-tarball smoke (opt-in)), 0 failed. All five type-check lanes pass. The legacy commit status issuccess.Suites and gates, from the os-dev report
5788876254, which the seat did not re-run:@objectstack/spec@objectstack/metadata-core@objectstack/plugin-auditcheck:generatedcheck:future-spec-majordispatch-gates --ranmainadded in the merged range.check:future-spec-majorwas checked against a lit control: re-planting18makes it exit 1 with exactly one problem.The tables below are the pre-merge readings, kept as history:
Every number in the tables below was taken at
7cc0ca1b3d, the pre-merge head.Reverse verification (both legs committed first, both restored byte-identically, both via
scripts/ablation-replace.mjs):sys_api_key→sys_api_key_ABLATED)0be02fdcc6b7→21856a4a20d0blob == HEAD,git diff HEADempty2e9e19825ef6→eea8b4c7f061expected 'Unrecognized key(s) on 'tenancy': 'or…' to contain ''tenancy.organizationField' was remov…'— the pin measures the PRESCRIPTION, not merely that parse throws; restore verified the same waySuites (
pnpm testper package, through the shared verify lock):@objectstack/spec@objectstack/metadata-core@objectstack/platform-objects@objectstack/plugin-security@objectstack/plugin-auditTypecheck:
@objectstack/spec,@objectstack/metadata-core,@objectstack/platform-objects,@objectstack/plugin-audit,@objectstack/plugin-security— all green, test layers included.Gates:
node scripts/pm/dispatch-gates.mjs --ranreconciles 114 derived / 114 run / 0 NOT-MEASURED / 0 UNRUN against this diff.pnpm --filter @objectstack/spec check:generatedreports 15 of 15 artifacts current.pnpm lint(eslint . --no-inline-config, the whole repo, no narrowing) exits 0.The three sanctioned platform-row writers' pins stayed green UNTOUCHED, as the card required —
plugin-approvals(approval-node,backfill-platform-row-organizations),service-automation(suspended-run-store),service-storage(backfill-sys-file-organizations): 33 + 52 + 15 tests, zero edits. Thedriver-sqlandtrigger-scheduleread-neutrality suites are green untouched too (36 + 61).Acceptance notes
Declared widening of the dispatched file surface — three files, each because this diff makes a statement in it FALSE. None was edited for tidiness; each is named with the measurement that forced it.
packages/spec/src/shared/alias-integrity.test.ts— RED. It pins the exact key set of the foldedtenancyguidance table:expected [ 'crossTenantAccess', …(2) ] to deeply equal [ 'crossTenantAccess', 'strategy' ]. The retirement adds the third row, which is the only channel the refusal travels on.packages/plugins/plugin-security/src/tenant-layer.test.ts— RED. It asserted the declaration off the shipped object:expected undefined to be 'active_organization_id'. Rewritten to pin what this suite actually owns: the stamp column exists as a field,organization_iddoes not, andtenancyis exactly{ enabled: false }.packages/plugins/plugin-audit/src/audit-writers.test.ts— RED, two cases, and one of them is a finding the card asked for. See the next section.A fourth file,
packages/spec/src/automation/schedule-organization.zod.ts, carried a docblock asserting "tenancy.organizationFieldwins there" — a statement this diff falsifies, and one that publishes, intocontent/docs/references/automation/schedule-organization.mdx. Corrected in prose; the generated page follows.⭐ Finding — one sanctioned writer's pin DID have to be edited, and the reason is not cosmetic. Two
plugin-auditcases went red:organizationFieldoutrankstenantField" pinned the precedence oncrm_lead, an object declaring BOTH keys, with the comment "No shipped object declares both; this pins the precedence so the day one does is not a coin flip." After the retirement no application can declare a stamp column at all, so the question is closed rather than answered. The case is rewritten to pin the closed set — an application object carrying a lookalike column stamps from its own wall.sys_api_keyschema with notenancyblock and pinned the actor's org, proving the stamp came from the declaration rather than from a column-name heuristic. Keying limb 0 by object name makes that shape stampactive_organization_idinstead. This is a real, deliberate behaviour change on a shape that is not reachable for the shipped table —sys_api_keyismanagedBy: 'better-auth'andprotection: { lock: 'full' }, so its block cannot be dropped. Recorded in the rewritten case rather than smoothed over, and the#5315guard that did not move (column absent ⇒ fall through to the actor's org) is pinned beside it.⭐ Finding — two issue citations this repo carries in these files do not resolve.
check-issue-citations --base origin/mainjudged 12 citations this change adds and refused all 12:#8778and#8707areallocated-but-absent(minted, ≤ frontier 19616, not on the board; deleted vs transferred NOT MEASURED). Both are pre-existing text — the diff only re-adds them by rewriting the docblocks around them. Following the gate's own prescription, the added lines now name the rulings in prose and cite the cloud record that does resolve. ⛔ No number was guessed. The standing occurrences on unchanged lines elsewhere in the tree are untouched and are not this PR's to repair.Stale-but-green fixture residue, deliberately NOT touched (green today, outside the dispatched surface, and not a defect — the fixtures feed drivers and engine doubles, never
TenancyConfigSchema):packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts,packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts,packages/plugins/plugin-approvals/src/{approval-node,backfill-platform-row-organizations}.test.ts,packages/services/service-automation/src/suspended-run-store.test.ts,packages/services/service-storage/src/backfill-sys-file-organizations.test.tsstill authortenancy: { …, organizationField: … }in raw object-definition fixtures. Their assertions remain true; what has gone vacuous is the claim that the driver / wall face is neutral about a key nobody can write.packages/lint/src/validate-object-field-refs.tscarries the key in a list of scalars it deliberately does not judge.No tree-scoped absence pin is added, and that is a decision rather than an omission: the playbook's tree-scoped form would have to declare its radius in
scripts/cross-package-test-inputs.mjsandturbo.json, both far outside this card's surface, and it would go red against exactly the six inert fixtures above. The absence is instead enforced where it is cheap and exact —authorable-surface/data.jsonhas no row, andcheck:authorable-surfaceis the gate over that baseline.Clause-② re-judged from the diff
no, and the diff agrees. No hunk puts a new key on a published payload: the guidance row is a prescription string, theRETIRED_KEYS_BY_MAJOR/CONVERSIONS_BY_MAJORentries are registry rows,json-schema/**loses a key, andapi-surface/is byte-identical —resolveRecordOrganizationField's signature is unchanged. This is a pure retirement, which narrows.🤖 Generated with Claude Code
https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Generated by Claude Code