Repository navigation
fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour - #21473
Conversation
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…ot see Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…e the anonymous doors cannot see Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…exists Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 776a90eef4ddcff243442f7645fbe08dbeea204a && git checkout 776a90eef4ddcff243442f7645fbe08dbeea204a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72af58c62167d21e5663c099a62f998f72afe81a ece345ca11412ef5d7c95799ef84bf6e95d5d0ac && git checkout -B drift-repro 72af58c62167d21e5663c099a62f998f72afe81a && git merge --no-ff ece345ca11412ef5d7c95799ef84bf6e95d5d0ac
node scripts/docs-audit/affected-docs.mjs --json 72af58c62167d21e5663c099a62f998f72afe81a
|
…lled-form-withdrawal
…lled-form-withdrawal # Conflicts: # packages/metadata-protocol/src/protocol.ts
…ody whose name disagrees with its row name, for every type, through the one judge (objectstack-ai#21470) (objectstack-ai#21536) Fixes objectstack-ai#21470 Clause-②: yes (narrowing) Every runtime door that writes a `sys_metadata` row now refuses a body whose own `name` disagrees with the name it writes the row under, for every metadata type, with `VALIDATION_ERROR` / 400, before anything is stored or registered. The refusal goes through the one judge objectstack-ai#21412 landed (`@objectstack/metadata/view-container-name`). The doors are `saveMetaItem`, `rollbackMetaItem`, the restore limb of `revertCommit`, and the draft promotion that `publishMetaItem` and `publishPackageDrafts` share. It follows the seat's answer on the card (5964758196: Q1 A on a premise, Q2 A), the claim's revision 2 (5964548518), and triage's direction 5962080068. ## The judge - `savedItemNameRefusal(type, item, saveName, door)` replaces `savedViewContainerNameRefusal(container, saveName)` on the subpath. `door` is `'save'`, `'restore'` or `'publish'`. - **The rule is row 1's predicate** (`assertMetadataRegisterContract`): a `name` the body carries (`!== undefined`) must equal the name the row is written under. There is one exception, and it belongs to the door, not the type: the save door stamps a missing view `name` after the judge runs. So for a `view` at `'save'`, a `name` counts as set only when it is a non-empty string. That is the container case's behaviour from objectstack-ai#21412, unchanged. - **Unchanged:** `viewContainerNameRefusal` (the source registrars' entry), its words, `objectql`'s re-export, the boot loop and `os validate`. - **Published surface.** The subpath has never shipped: - `npm view @objectstack/metadata@latest exports --json | grep -c view-container-name` prints `0`. The control: `"./view-container"` counts `1`, and latest is `17.6.0`. - `git ls-tree origin/main .changeset/21412-metadata-view-container-name-judge.md` prints the blob line `c8df04b2…`. - Both were re-checked before this push, on `origin/main` `c98a72d69e`. - So no published export is removed. The PR's line reads `Clause-②: yes (narrowing)` because the subpath's export set changes against `main`. ### The container case is byte for byte unchanged I rendered the save-door words for P1, P3 and P4 and the derived entry's words for P1, before the change (`savedViewContainerNameRefusal`) and after it (`savedItemNameRefusal('view', …, 'save')`), each from the built `dist`. Both renders give the same sha256, `0f65c121514e148caae28f82f03b64db2fcd21c04b561ccb75f73af4bb629352`, and `cmp` reports them identical. The control: after the build, the `dist` has 0 hits for the old name and 2 for the new one. ### The words for every other body and door (rendered from `dist`) ```text Invalid dashboard: its own `name` is 'dash_b', which disagrees with the name it is saved under, 'dash_a'. A disagreement is almost always an authoring bug, and resolving it silently in either direction can file the item under a key the caller never wrote (refuse loudly, locate the mismatch). Register under one name: set `name` to 'dash_a', or save the item under 'dash_b'. Invalid view: its own `name` is 'crm_lead.other', which disagrees with the name it is saved under, 'crm_lead.mine'. … Register under one name: drop `name`, or set it to 'crm_lead.mine'. Invalid field: its own `name` is 'zz_probe', which disagrees with the name it is saved under, 'crm_task.zz_probe'. … Register under one name: drop `name` (a `field` row is named object.field, which its column `name` cannot spell). Invalid dashboard version: its own `name` is 'dash_b', which disagrees with the name it is restored under, 'dash_a'. … Register under one name: save the item with `name` set to 'dash_a', or under 'dash_b', instead of restoring this version. Invalid dashboard draft: its own `name` is 'dash_b', which disagrees with the name it is published under, 'dash_a'. … Register under one name: save the draft again with `name` set to 'dash_a', or under 'dash_b', then publish it. ``` Each remedy is true for its type and its door: - **`drop name`** is offered only where dropping works: a view (the save door stamps a missing name), and a `field`. `FieldSchema` does not require `name`, and its `name` is dot-free, so it can never equal an `object.field` row name. - **`set name`** is offered for every other type, together with the opposite direction: save the item under its own `name`. A save name the type's schema cannot spell leaves only that direction. Measured: 22 of the 27 schema'd registry types refuse a dotted body `name`, and the save door's grammar admits dotted row names. - **At the restore and publish doors**, the remedy is the save that fixes the stored body, because their caller cannot edit a stored version or draft in place. ## Callers in `protocol.ts` - **`saveMetaItem`**: the existing call site, now for every type, still before `normalizeViewMetadata`. ⛔ PR objectstack-ai#21473's public-form lines are not touched; its nearest hunk sits about 230 lines above. - **`rollbackMetaItem` and `revertCommit`'s restore limb**: through a new private `restoredBodyWriter(type, name)`. It is the `deriveRestoredBody` that `repo.restoreVersion` calls on the very history body it read, before it reads the active row and before `put`. It runs the judge first and then the existing credential-channel strip (objectstack-ai#20790 R2). A refused version writes nothing: - `rollbackMetaItem` rethrows the refusal; - `revertCommit` reports `failed[]` with `code: 'VALIDATION_ERROR'`. - **`promoteDraftForPublish`**: judges the `draftForGate` body before `repo.promoteDraft` writes, beside the existing authoring gate and in the same way. ⛔ PR objectstack-ai#21473's promotion gate inside `publishMetaItem` (about `:19410`) is not touched. ## Census of at-rest rows (triage step 1) Taken on `objectstack-ai/objectstack` at `e9dec3dab`. Each bootable example booted with `--fresh` and its seeds. Every `sys_metadata` and `sys_metadata_history` row was read straight from the fresh SQLite file (read-only), and each body's `name` was compared with its row's `name`. | app | boot | seeds | `sys_metadata` rows | body name differs | `sys_metadata_history` rows | body name differs | |---|---|---|---|---|---|---| | app-crm | `pnpm dev:crm -- --fresh` | 28 | 0 | 0 | 0 | 0 | | app-todo | `pnpm dev:todo -- --fresh` | 8 | 0 | 0 | 0 | 0 | | app-showcase | `pnpm dev -- --fresh` | 132 | 0 | 0 | 0 | 0 | | app-multi-package | `pnpm --filter @objectstack/example-multi-package dev -- --fresh` (no root script) | none printed | 0 | 0 | 0 | 0 | | embed-objectql | not bootable (a vitest demo) | n/a | no `sys_metadata` table: no metadata protocol in its closure | n/a | n/a | n/a | | hosted tenant | **NOT MEASURED**: no cloud access in this session | | | | | | - **Control (the reader sees WAL-resident data):** `sys_user` reads 1 / 1 / 3 / 1 and `sys_permission_set` reads 10 / 8 / 17 / 8 in the same four files. - **Step 3 needs no conversion on this corpus.** The census finds 0 rows, so on the measured corpus there is nothing to convert first. - **A stored row stays readable.** A row stored before this change keeps its bytes. The write doors now refuse to re-write it: a `migrateStoredMetadata` pass reports it `failed`, and a rollback, revert or publish of it is refused with the remedy. ## Measured before the change (`origin/main` `e9dec3dab`, a probe battery since deleted) - **P6** (record view, row `crm_lead.mine`, body `name` `crm_lead.other`): accepted. The registry key was `crm_lead.other` only. - **P7** (dashboard, row `dash_a`, body `name` `dash_b`): accepted. The registry key was `dash_b` only. - **R1** (`rollbackMetaItem` to a stored version whose body `name` is `dash_b`): it restored that version with 0 `saveMetaItem` calls. The key was `dash_b`. - **R2** (`revertCommit`, `prevVersion` that version): `revertedCount: 1` with 0 `saveMetaItem` calls. The key was `dash_b`. - **D1** (`publishMetaItem` of a draft row `dash_d` whose body `name` is `dash_e`): promoted with 0 `saveMetaItem` calls. The key was `dash_e`. - **An empty or non-string `name` on a non-container type** (the seat's added pin), measured per type against `getMetadataTypeSchema`: - 24 of the 27 schema'd registry types already refuse `''`, `7` and `null` (422). - `seed` declares no `name` at all, so it refuses any `name`. - `view` stamps a falsy `name` (and the schema refuses `7`). - `translation` **accepts `name: ''`**. - `external_catalog` has no schema, so it accepts anything. - So `''` reaches persistence for `translation`, and it is keyed `''` in the registry; any value reaches persistence for `external_catalog`. The judge therefore refuses a set non-view `name` whatever its value. See the first item under the decisions below. ## Pins All in `packages/metadata-protocol/src/protocol.item-name-every-door.test.ts`. It is a stub engine that stores rows and history, whose registry keys an item by its `name`, and whose transaction rolls back on a throw (ADR-0067 D2). It runs on the topology where non-`object` types write through to the shared registry. - **P6, P7, and `translation` with `name: ''`:** refused with `VALIDATION_ERROR` / 400. Nothing is stored and nothing is registered. - **Equal or absent `name` passes:** a dashboard stored and keyed `dash_a`. A nameless record view is stamped and keyed by its row. A nameless dashboard passes the judge and meets its schema's own 422. - **One registry key per row:** asserted on every control. - **R1, R2 and D1 refused with P6/P7's envelope, nothing written.** The active row, the history length and the registry are unchanged; there are no `saveMetaItem` calls; the draft is kept. Each has a clean control through the same door. - **The `publishPackageDrafts` batch case:** one refused draft aborts the batch, as the authoring gate's refusal does. The outcome is `refused` and `failed[]` lists the refused draft with `VALIDATION_ERROR` and its sibling as aborted. Nothing goes live, and the registry is empty. A clean control publishes both. - **The judge's own pins** (`packages/metadata/src/view-container-name.test.ts`): every type; every door; what counts as set (row 1's predicate, the view stamp only at the save door); the remedy per type and per door; and `field`. - The SCOPE pin ("a standalone ViewItem is not judged here") flips by design. The stored bodies that R1, R2, D1 and the batch case need are staged the way they exist in a deployment. A clean body goes through the real door, and its stored bytes are then rewritten in the double, because after this change no door writes one. ## Ablation and reverse verification (each from a committed state, through `scripts/ablation-replace.mjs`) Each run's direction was declared before it ran, and each observed result matched: | run | mutation | result | |---|---|---| | A1 | neutralize the `saveMetaItem` call (src) | the every-door file 3 red / 9 green (P6, P7, `translation`); `view-container-runtime-expansion.test.ts` 5 red (objectstack-ai#21412's P1 ×3, P3, P4) | | A2 | neutralize the restore writer's judge (src) | 2 red / 10 green (R1, R2) | | A3 | neutralize the publish judge (src) | 2 red / 10 green (D1, the batch case) | | Reverse, through `dist` | the judge's write-door entry put back to container-only, in `packages/metadata/src`, then rebuilt | `ablation-dist-preflight` found the marker in 2 built files; the every-door file 7 red / 5 green (every refusal red, every control green); the container file stays green | - **Every run restored cleanly.** Each restore leg ended with the blob equal to `HEAD` and an empty `git diff HEAD`. - **The reverse run's restore leg:** a rebuild, the `--absent` preflight, and 12 / 12 green. - **A refused first attempt:** the first reverse attempt was refused by the tool before anything ran, because its replacement contained the anchor. It was recorded as a non-run and rerun with a non-overlapping replacement. ## Tests (at `181408e1e5`; core pins again at `056df2c896` after the last merge of `main`) - `@objectstack/metadata`: `src/view-container-name.test.ts` passes 19 / 19, and the full suite earlier passed 858 / 858. - `@objectstack/metadata-protocol`: the full suite passes 3163, with 19 skipped. At `056df2c896` the every-door and container files pass 131 / 131. - **Downstream files that exercise the write doors,** run at the earlier head with a rebuilt `metadata-protocol` `dist`: - `objectql`: 42 files, 534 tests; - `rest`: 53 files, 1363 tests; - `runtime`: 46 files, 1557 tests; - `plugin-security`: 7 files, 150 tests; - `service-automation`: 2 files, 8 tests; - `plugin-email`, `service-cluster`, `mcp`, and `cli` (unit tier): 2 + 1 + 2 + 2 files. - All green after the fixture triage below. - `typecheck`: `metadata`, `metadata-protocol` and `objectql` all green (the last including `check:test-typecheck`, 65 pinned signatures held). `--listFiles` confirms the new and edited test files are compiled. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 13 touched `.ts` files gave 13 results, 0 errors, 0 warnings, and none ignored. The touched population is all of them: `eslint.config.mjs` lints `**/*.{ts,…}` minus its `NEVER_LINTED` set. Untouched files cannot move, because the config enables no type-aware linting (no `parserOptions.project`, as its own header states). - **Left to CI:** `packages/qa/dogfood` (25 files touch these doors; the PUT bodies I read there name their row or echo a GET), `qa/http-conformance`, and the `cli` integration tier. ## Gates `dispatch-gates --commands` on the final diff derived 74 families, a superset of the PM's lead. The 74 are its 56 plus 18: the changeset, objectql and ledger families. `--ran` with each exit code recorded answered `74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED`. 73 exited 0. At `056df2c896` the ratchet family was rerun: `engine-double-contract`, `objectql-double-limit`, `query-options-erasure`, `slot-lookup`, `where-matcher`, `type-check-debt`, `type-check-coverage`, `doc-authoring`, `cross-package-test-inputs`, `test-source-alias`, `nul-bytes`, `keyed-text-bounds`, `undeclared-dep-imports`, `adr-0087-registration` and `changeset-no-major`. All exited 0. - **`check:engine-double-contract`** asked for the new test's pinned double to be recorded (`--write`). That is the 15-line addition to `scripts/engine-double-contract.pinned.json`, and nothing else moved. - **`check-empty-changeset` exits 1, deliberately: it is a DELIBERATE CORRECTION.** It needs confirmation on this PR (see below). - **`check-changeset-no-major`'s clause-② axis** reads `NOT APPLICABLE` locally (there is no `pull_request` payload); CI reads it on this PR. ## Changesets - `.changeset/21470-metadata-write-door-item-name-judge.md`: `@objectstack/metadata`, minor, `Clause-②: yes`. - `.changeset/21470-metadata-protocol-every-write-door-item-name.md`: `@objectstack/metadata-protocol`, minor, with the **BREAKING** banner and `Clause-②: no (narrowing)`. Its ADR-0087 disposition is `not-required (no-migration-prescription)`, with the census (0 rows) in the marker, as PR objectstack-ai#21483's was. - **Two pending objectstack-ai#21412 release notes are corrected, because this PR makes a sentence in each false.** They are named here for confirmation, as `check-empty-changeset` asks: - `.changeset/21412-metadata-view-container-name-judge.md`: the sentence naming `savedViewContainerNameRefusal(container, saveName)` now names `savedItemNameRefusal(type, item, saveName, door)` and says it judges every type. The seat ordered this one (5964758196). - `.changeset/21412-metadata-protocol-save-door-container-name.md`: its last line read "Not judged here: a standalone view record (`viewKind`) and every other metadata type". The same release now judges them, so the line points to this PR's entry. ⚠ The seat's answer named only the first note. This second one is my addition, under "every changeset sentence must be true". ## Decisions the review should check 1. **An empty or non-string `name` on a non-view type is refused by the judge,** which runs before the schema. Where the type's schema already refused such a body (`''`, `7` or `null` on 24 types; any `name` on `seed`), the answer moves from the schema's `INVALID_METADATA` / 422 to `VALIDATION_ERROR` / 400. Nothing is stored either way. - The seat's text said "If the schema already refuses it, record that and add nothing". I did not make the judge schema-aware to honour that per type, because that would be a second rule keyed on schema knowledge. One predicate (row 1's) covers both `translation`'s `''` and `external_catalog`'s anything. - The changeset says so. 2. **`field`.** A `field` row is named `object.field`, and its canonical body carries the dot-free column `name`. Registered, the row answered under the column name, and every object's `title` field collided on one key. That is pin 3's defect, so the judge refuses it, and the remedy is "drop `name`". - The type is code-only (objectstack-ai#5086) and was ruled REMOVE (objectstack-ai#7893), so this is reachable only through the `OS_METADATA_WRITABLE` operator hatch. - The hatch-path fixtures in two test files now send a nameless field body: `protocol.code-only-types.test.ts` and `protocol.destructive-gate-reachable-types.test.ts`. What those tests measure (the hatch's routing, the destructive gate's reach) is unchanged. - The alternatives were to exempt `field` (which keeps the collision) or to judge it against the column half of its row name (a type-specific key derivation). 3. **The `door` parameter and the two-direction remedy go beyond the seat's suggested `savedItemNameRefusal(type, item, saveName)`.** They exist so the remedy is true at a door whose caller cannot edit the stored body, and for a save name the type cannot spell. ## Fixture triage (bodies that only used a constant `name`) The rule's consumer radius covers other packages' fixtures, so they were swept and re-judged. Each fixture below only used the `name`, so each was rewritten to name its row, or to send none where the door stamps one. What each test measures is unchanged. - `metadata-protocol`: `protocol.item-name-grammar.test.ts` (`VIEW_BODY` is now nameless; the door stamps the request name) and `protocol.runtime-gate-stored-universe.test.ts` (`oneWidgetBoard` takes the row name). - **`objectql`:** - `protocol-recorded-by-null.test.ts` (`viewBody` takes the row name); - `protocol-save-meta-repo-path.test.ts` (`view_one` becomes `v`); - the two `*-meta-response-conformance.test.ts` files (`cleanFlow` is named `bounded_purge`, the row it is saved under). - `field`: see the decisions above. ## Not in this PR - **Boot hydration** (`loadMetaFromDb`, then `hydrateOverlayIntoRegistry`) keeps registering a row stored before this change under its body `name`. - It is residue only: once the write doors judge, no new row can diverge. - The census found 0 such rows on the examples; the hosted tenant is NOT MEASURED. - It is a reader, outside this claim (⛔ not `getMetaItem`, `readFlattenedMetaItems` or `hydrateExpandedViewItems`; objectstack-ai#21510 and objectstack-ai#21511 are queued behind this card). - A measured hosted instance would be the trigger to file it. - **A non-view body with no `name`** still registers nothing at all (`hydrateOverlayIntoRegistry` skips a nameless body). This is pre-existing, and triage's pin 2 says an absent `name` passes. --- _Generated by [Claude Code](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…serve write returns at the reader-context seams (objectstack-ai#21539) Part of objectstack-ai#21454 Clause-②: yes (narrowing) The follow-on to PR objectstack-ai#21513, on the same reader-context seam. objectstack-ai#21513 served the stored-metadata-body family's reads (body projected, content hash keyed) at the in-process reader contexts. This PR closes the two positions triage routed to the card's next claim, through the generic data door's own code — no copy: - **Evaluate-shape refusals** (triage `5964426684` item 2, with `5963299937`). A filter, sort, grouping or search that would EVALUATE the stored body or a content-hash column of the family, arriving through a reader context (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`), is now refused with the door's own `INVALID_FIELD` / 400 before the query runs. A `count` carrying such a predicate — the oracle verb — is refused too (it serves no row). A default search is NARROWED to the door's served field set (the body and content-hash columns removed, judged field by field by the door's own search predicate) rather than refused, so a reader context may still search a family table by its scalar columns exactly as the door serves it; a search that narrows to nothing is refused. - **Serve what a write verb returns** (triage `5964426684` item 4). A write whose return carries the family's body or content hash is served projected and keyed, the same way a read is — a returned row is a serve. Both are executed through the four refusal predicates the door uses, now exported from `@objectstack/metadata-protocol` (`storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`). The search narrowing consumes the spec's own `resolveSearchFieldResolution` and the door's search predicate as the authority on which columns a search may never scan. Field collection for the filter/sort refusals uses `@objectstack/plugin-security`'s `collectConditionFields` (the door's sibling collector; the door's own `collectFilterFieldKeys` is internal to `protocol.ts`, PR objectstack-ai#21473's file, and unreachable here). That collector gates on a dotted head and reads a cross-field reference, so it refuses MORE than the door — a dotted or cross-field reference to a family column the door's collector would miss — strictly in the safe direction, never a legitimate scalar-column query. ### The engine action verb (`ScopedRepo.execute`), measured (triage `5964836549` item 1) The reach reading: `execute` is UNREACHABLE from a served body. The sandbox VM bridge exposes only `find` / `findOne` / `count` / `aggregate` and the writes to a body — no `execute`, no `sudo`, no `withRunAs` — so a served body can never hand a raw scoped context to a nested action. The seam therefore leaves `execute` untouched and records the reading (pinned: a body's `typeof ctx.api.object(...).execute` is `undefined`). ### Where objectstack-ai#21520's write-verb refusal attaches The maintainer approved objectstack-ai#21520 option A (an app-authored body may not write the family's tables). That refusal is a SEPARATE card and is NOT implemented here. It attaches on the same write verbs this seam wraps, in `serveRepository`'s write branch, as a throw BEFORE the write runs — it needs no reshaping of this seam. A code comment names that point. Measured on `main` today (pre-objectstack-ai#21520): an elevated body's family-table write is not refused and returns the stored row, so the write-return serve in this PR carries real content; it also covers the host-handler write path, which objectstack-ai#21520's body refusal does not reach. ### Reverse verification (ablation) Each new behavior ablated on disk (`scripts/ablation-replace.mjs`, mutation proven by blob hash + anchor count, restored to the HEAD blob), src-resolved (the pins import the seam by relative path): - disabling the evaluate refusal turned the 6 refusal / narrowing pins red; the serve, write-return, scalar and unreachability pins stayed green; - disabling the write-return serve turned exactly the write-return pin red; all else green. ### Tests and gates - New pins: `stored-metadata-reader-seam.test.ts` (17) against a scoped-API double, and `stored-metadata-reader-contexts.pin.test.ts` (26) end to end through a booted kernel, for administrator and member alike. - `@objectstack/runtime` suite 4413 passed / 19 skipped; `@objectstack/metadata-protocol` suite 3151 passed / 19 skipped; both typecheck clean. - All 70 dispatch-derived gate families green (`check:engine-double-contract` pinned-ledger entry added through the gate's own `--write`). - Lint narrowing: the 4 changed TS files lint 0 errors / 0 warnings; the `.md` and `.json` have no matching eslint config; `eslint.config.mjs` enables no type-aware linting and no cross-file import rules, so this diff cannot move the verdict on any untouched file (the farm-wide `pnpm lint` is CI's). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ion in words instead of a tracker number (stage 5) (objectstack-ai#21568) Part of objectstack-ai#20749 Clause-②: no Stage 5 of the `domain:spec` lane's share of the runtime-string burn-down (ruling `5902360492`, form D): the rest of class (b), the protocol 17 → 18 conversion summaries in `packages/spec/src/conversions/registry.ts`. Every rewritten summary now states in words what the cited decision was, or drops a citation its sentence already explained. Text only. ## What changed - **35 ADR-0087 conversion summaries** (40 tracker ids, 34 distinct cards): every toMajor-18 summary that carried an id, from `field-malformed-scale-precision-removed` to `flow-decision-mode-inclusive-explicit`. A summary is what `os migrate meta --json` reports under `specChanges` (its chain already runs to protocol 18, `CHAIN_TERMINUS_MAJOR`), and it becomes the upgrade guide's "Change" column and the `to` text of `spec-changes.json`'s `converted[]` once protocol 18 ships, so an author upgrading metadata reads it. - One `@objectstack/spec` **patch** changeset, `Clause-②: no` (message text only). - **No generated file changes** (A2 below): no generator projects a toMajor-18 summary today. ## Census at the base (A1) Stage 4's instrument (`convtable.cjs`, byte-identical copy, md5 `9b7539067ffc38598172c692de637db5`) at base `e901c27449` (the worktree before any edit): 127 conversions, **35 summaries with ids, 40 id occurrences, 34 distinct cards** — stage 4's split unchanged. The toMajor-18 conversion that PR objectstack-ai#21547 added carries no id. Under the ~60-card bar, so one stage. The stage-3 census re-run at the same base agrees (class b: 35 messages / 40 ids; nothing else in class b). | file:line (base) | id | conversion | |---|---|---| | `registry.ts:6551` | objectstack-ai#8321 | `field-malformed-scale-precision-removed` | | `registry.ts:6659` | objectstack-ai#8762 | `record-chatter-position-vocabulary` | | `registry.ts:6777` | objectstack-ai#9198 | `element-input-target-variable-removed` | | `registry.ts:7024` | objectstack-ai#9220 | `element-filter-removed` | | `registry.ts:7177` | objectstack-ai#9249 | `element-form-removed` | | `registry.ts:7356` | objectstack-ai#15178 | `translation-per-app-settings-removed` | | `registry.ts:7356` | objectstack-ai#19620 | `translation-per-app-settings-removed` | | `registry.ts:7610` | objectstack-ai#9249 | `translation-component-submit-label-removed` | | `registry.ts:7782` | objectstack-ai#3951 | `field-column-lists-canonicalized` | | `registry.ts:7783` | objectstack-ai#9227 | `field-column-lists-canonicalized` | | `registry.ts:7909` | objectstack-ai#10414 | `metric-filters-removed` | | `registry.ts:8104` | objectstack-ai#17296 | `cube-sub-day-granularities-removed` | | `registry.ts:8237` | objectstack-ai#18612 | `cube-join-sql-and-relationship-removed` | | `registry.ts:8502` | objectstack-ai#10054 | `record-highlights-field-icon-removed` | | `registry.ts:8759` | objectstack-ai#11027 | `page-component-responsive-removed` | | `registry.ts:8860` | objectstack-ai#11805 | `object-grid-default-sort-removed` | | `registry.ts:9047` | objectstack-ai#21445 | `object-grid-resizable-columns-removed` | | `registry.ts:9250` | objectstack-ai#17260 | `object-kanban-quick-add-removed` | | `registry.ts:9634` | objectstack-ai#12497 | `permission-allow-restore-purge-removed` | | `registry.ts:9637` | objectstack-ai#1883 | `permission-allow-restore-purge-removed` | | `registry.ts:9954` | objectstack-ai#6837 | `field-reference-to-alias` | | `registry.ts:10372` | objectstack-ai#14478 | `hook-timeout-to-timeout-ms` | | `registry.ts:10413` | objectstack-ai#14478 | `job-timeout-to-timeout-ms` | | `registry.ts:11017` | objectstack-ai#14478 | `api-endpoint-cache-ttl-to-cache-ttl-seconds` | | `registry.ts:11086` | objectstack-ai#14478 | `dashboard-refresh-interval-to-refresh-interval-seconds` | | `registry.ts:11447` | objectstack-ai#14478 | `memory-persistence-auto-save-interval-to-ms` | | `registry.ts:11671` | objectstack-ai#14478 | `turso-config-timeout-to-timeout-ms` | | `registry.ts:11761` | objectstack-ai#17063 | `view-page-mount-removed` | | `registry.ts:11866` | objectstack-ai#17053 | `list-view-sort-string-clause-to-array` | | `registry.ts:11868` | objectstack-ai#8221 | `list-view-sort-string-clause-to-array` | | `registry.ts:12366` | objectstack-ai#19054 | `object-tenancy-organization-field-removed` | | `registry.ts:12476` | objectstack-ai#20085 | `view-item-owner-hidden-removed` | | `registry.ts:12620` | objectstack-ai#20230 | `view-overlay-owner-hidden-removed` | | `registry.ts:13214` | objectstack-ai#17321 | `page-component-filter-record-to-rule-array` | | `registry.ts:13214` | objectstack-ai#6206 | `page-component-filter-record-to-rule-array` | | `registry.ts:13463` | objectstack-ai#20161 | `report-joined-chart-removed` | | `registry.ts:13728` | objectstack-ai#20221 | `form-layout-inline-grid-to-vertical` | | `registry.ts:13884` | objectstack-ai#19992 | `currency-config-precision-removed` | | `registry.ts:13988` | objectstack-ai#20321 | `permission-rls-tags-removed` | | `registry.ts:14128` | objectstack-ai#15429 | `flow-decision-mode-inclusive-explicit` | ## Projections (A2) Neither generator projects a toMajor-18 summary at this base. `build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `major` from `MIGRATION_SUPPORT_FLOOR + 1` to `PROTOCOL_MAJOR`, which are 16 and 17 here (`PROTOCOL_VERSION = '17.0.0'`), so `spec-changes.json` carries one `perMajor` record (16 → 17) and the guide one "Protocol 16 → 17" table. `check:generated` reads all 15 artifacts up to date on this head with no regeneration, so no generated file is in the diff. Both projections will pick these summaries up when protocol 18 ships. ## Delivered: each site, the decision read, the new words (A3) Every cited card was read through REST with all its comments (30 objectstack cards, objectui#3951, objectstack-ai#6206, objectstack-ai#6837, objectstack-ai#8221). The record column names the comment the decision was read from. Where a summary already said why, the citation is dropped and the sentence kept; where an `(#N, ADR-0049 — …)` opener cited both, the card number goes and the ADR stays, as stage 4 did. In the `cube-join-sql-and-relationship-removed` row, `ALIAS` stands for the angle-bracket placeholder in the source. | conversion (head line) | cited | decision as read (record) | summary now reads | |---|---|---|---| | `field-malformed-scale-precision-removed` (:6550) | objectstack-ai#8321 | refuse a malformed scale/precision at the producer (z.number().int().min(0)); a stored malformed value takes the D2 strip so the row stays loadable; citation dropped, the sentence already said it (body + ACCEPT 5296942541) | malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — they were silently unenforced; the schema now refuses them at authoring | | `record-chatter-position-vocabulary` (:6658) | objectstack-ai#8762 | the row's vocabulary converges on the renderer's bottom/right/left: one vocabulary, no mapping layer; the three old spellings take a conversion (ruling 5299771841) | record:chatter / record:discussion 'position' respelled to the renderer's vocabulary — 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (one vocabulary, the renderer's, rather than a mapping layer between two: the renderer compares only bottom/right/left, and the old set fell through every branch) | | `element-input-target-variable-removed` (:6778) | objectstack-ai#9198 | ADR-0049 enforce-or-remove: verdict dead (a declarative hint with zero readers), retired with tombstones and a D2 conversion (ACCEPT 5311252358 (PR body verdict)) | text-input/record-picker component prop 'targetVariable' removed (retired under ADR-0049 enforce-or-remove as a declarative hint nothing read; the live binding resolves from the page variable whose `source` names the component id) | | `element-filter-removed` (:7025) | objectstack-ai#9220 | dead at ELEMENT grain (no renderer anywhere; Studio excludes it from the palette), so the whole element retires under ADR-0049, not key by key (verdict 5312176877 + ACCEPT 5312709553) | the whole 'element:filter' element retired (ADR-0049 enforce-or-remove at element grain, not key by key — no renderer for it ever shipped in any repo, so every key was a capability claim nothing kept; list surfaces own their filtering via a view's userFilters / the list filter builder). All six props are stripped; the bare node the conversion leaves is refused by name at the parse, with the prescription to delete the component | | `element-form-removed` (:7179) | objectstack-ai#9249 | dead at element grain, the objectstack-ai#9220 precedent: the whole element retires; the palette already names object-form as the replacement (dev report 5384430470 (verdict re-taken in the PR body)) | the whole 'element:form' element retired (ADR-0049 enforce-or-remove at element grain, not key by key — no renderer for it ever shipped in any repo, so every key was a capability claim nothing kept; use the object-bound 'object-form' block instead — rendered and designer-publishable). All six props are stripped; the bare node the conversion leaves is refused by name at the parse, with the prescription to delete the component | | `translation-per-app-settings-removed` (:7358) | objectstack-ai#15178, objectstack-ai#19620 | objectstack-ai#15178: the bundle type splits, the platform bundle keeps `settings`, a per-app bundle refuses it (settings is a platform key). objectstack-ai#19620 ruling B: `settings` leaves the translation item too, because the file door and the item door are two authoring surfaces of one app metadata type and accept one shape (ruling 5653315643 (objectstack-ai#15178); ruling 5770445203 (objectstack-ai#19620)) | translation group 'settings' removed from both application-authored faces, the per-app bundle entry and the registered translation item: settings copy belongs to the platform, and the two authoring doors of one application translation type accept one shape. It is keyed by SettingsManifest.namespace and only platform code declares a manifest. A per-app bundle entry could only fill gaps the platform's own bundle left in the one merged served tree, and was overwritten wherever both defined the key; a stored item OVERRODE the platform copy, because the runtime-authored layer is read over the shipped bundles. Overrides now give way to the platform copy, gaps fall back to the manifest literal, and the group stays on the PLATFORM bundle, PlatformTranslationData | | `translation-component-submit-label-removed` (:7613) | objectstack-ai#9249 | `element:form` retired whole because no renderer for it ever shipped (dead at element grain), which left `submitLabel` with no carrier (dev report 5384430470) | translation component-copy key 'submitLabel' removed (retired rather than re-anchored — its only declared carrier, 'element:form', retired whole because no renderer for it ever shipped, so the resolver no longer overlays it and a stored string was read by nothing; the live form surface's submit copy is 'object-form''s 'submitText', localized at its own authoring site, and re-anchoring the key there would only have added a second place to translate one word) | | `field-column-lists-canonicalized` (:7787) | objectui#3951, objectstack-ai#9227 | objectui#3951: the published spec spelling `name` wins and the grid reader is fixed to read it. objectstack-ai#9227: `inlineColumns` gets a strict name-keyed element schema (an unknown key is a named rejection at publish, not a blank cell); `relatedListColumns`, checked in the same pass, takes field-name strings (ruling 5236150020 (objectui#3951); ruling 5315735776 + ACCEPT 5317488979 (objectstack-ai#9227)) | inline-grid column entries respelled 'field' → 'name' (the declared spelling wins, and the grid renderer now reads 'name' too) and related-list column objects folded to their child field-name string (both lists were z.any(), so a mis-keyed column published clean and rendered blank cells; inline columns now take a strict name-keyed shape and related-list columns plain field names, so a mis-keyed column is refused at publish) | | `metric-filters-removed` (:7916) | objectstack-ai#10414 | the remove leg of enforce-or-remove: zero consumers (measured with a positive control) and a raw-SQL carrier; retire per the playbook; citation dropped, the sentence already said it (triage grading 5363699539) | cube metric key 'filters' removed (ADR-0049 — no strategy ever read it: the authored raw-SQL condition was parsed and dropped, and the query returned the unfiltered aggregate. Filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter`; a metric's own `sql` is a column reference) | | `cube-sub-day-granularities-removed` (:8111) | objectstack-ai#17296 | each of second/minute/hour is residue and removed: no layer outside the enum names them and `queryDateGranularity` cannot advertise them; ADR-0049 prefers removal with no committed roadmap; citation dropped (dev report 5648182389 + landing 5648923185) | cube dimension granularities 'second' / 'minute' / 'hour' removed (ADR-0049 — no backend bucketed them and none could advertise them: `supports.queryDateGranularity` is a record over `DateGranularity`, which declares day, week, month, quarter, year. Offer the coarsest interval that still answers the question) | | `cube-join-sql-and-relationship-removed` (:8244) | objectstack-ai#18612 | retire `sql` and `relationship` from CubeJoin: the join is derived from the FK relationship and no author-supplied ON clause executes; the addendum adds the D2 strip for persisted artifacts; citation dropped, the sentence already said it (ruling 5725370783 + addendum 5727426171) | cube join keys 'sql' and 'relationship' removed (ADR-0049 — neither was ever read: both strategies synthesise the ON clause as a foreign-key equality, so an authored join condition was REPLACED under a 200 and a declared cardinality changed no SQL. Keep `joins.ALIAS.name` alone; the record KEY is the foreign-key field on the base object) | | `record-highlights-field-icon-removed` (:8509) | objectstack-ai#10054 | option A: measured dead (zero read points, not designer-publishable), so it retires under the ADR-0087 flow; citation dropped (ruling 5364978909) | record:highlights highlight-field key 'icon' removed (ADR-0049 — no render path: the highlight chip has no icon slot, the register hook carries field names only, and the Studio designer publishes the field list as plain strings, so an authored icon was accepted and drawn by nothing) | | `page-component-responsive-removed` (:8766) | objectstack-ai#11027 | ruling B: retire `page.components[].responsive` (ADR-0049, wired into no renderer) and repair the texts that redirected authors to it (ruling 5380752244) | page component key 'responsive' removed (ADR-0049 enforce-or-remove — no renderer ever applied per-component breakpoint layout overrides, and the shared ResponsiveConfig shape leaves with its last carrier; use responsiveStyles (ADR-0065) for breakpoint behaviour that IS applied) | | `object-grid-default-sort-removed` (:8868) | objectstack-ai#11805 | retire object-grid `defaultSort` (the strict route per the playbook), completing the objectui-side direction ruling at the producer (ruling 5404972152) | object-grid component prop 'defaultSort' removed (retired under ADR-0049 enforce-or-remove as the legacy single-sort second spelling of 'sort', read only when 'sort' was absent; the pair moves to sort: [{ field, order }], the array shape every read path honours) | | `object-grid-resizable-columns-removed` (:9055) | objectstack-ai#21445 | `resizable` is canonical and `resizableColumns` retires now as a tombstone naming it: zero writers, so no window (immediate retirement) (triage direction 5958164933) | object-grid component prop 'resizableColumns' removed (the legacy second spelling of 'resizable', read only when 'resizable' was absent, retires at once so 'resizable' is the one spelling; the value moves to 'resizable' when that is absent, and is deleted when it is present) | | `object-kanban-quick-add-removed` (:9258) | objectstack-ai#17260 | option B: `quickAdd` leaves `object-kanban` (accepted and dropped there); this repo carries the tombstone half (card body (the objectui ruling it executes, option B) + triage 5620331176) | object-kanban component prop 'quickAdd' removed (retired from the board under ADR-0049 enforce-or-remove — the affordance is gated on a host-supplied 'onQuickAdd' function no producer puts on an object-kanban node, so the key was accepted and dropped; delete the key — object-kanban offers no quick-add control) | | `permission-allow-restore-purge-removed` (:9643) | objectstack-ai#12497, objectstack-ai#1883 | option B: retire `allowRestore` / `allowPurge`, which gate operations that do not exist; objectstack-ai#1883 stays open as the M2 anchor, where undelete/purge ship as feature + RBAC in one batch and the keys return with it (card body (objectstack-ai#12497); ruling 5421209848 (objectstack-ai#1883)) | object-permission keys 'allowRestore' and 'allowPurge' removed (ADR-0049 — the `restore`/`purge` operations they claimed to gate have never existed, so granting the bits delivered nothing; dispatched destructive lifecycle verbs stay denied fail-closed. The keys return with the M2 lifecycle initiative, which builds undelete and purge together with the permission bits that gate them) | | `field-reference-to-alias` (:9962) | objectui#6837 | ruling C: protocol normalisation belongs to the server and the frontend only executes the protocol; half 1 (this repo) guarantees the serve path carries only `reference`, half 2 deletes objectui's legacy fallback arms (ruling 5475017957 + half-1 pointer 5475055291) | field key 'reference_to' → 'reference' (the legacy objectql runtime dialect for a lookup/master_detail target; normalising to the protocol is the server's job and the renderer only executes the protocol, so stored rows must serve the canonical spelling before objectui deletes its `reference ?? reference_to` fallback arms) | | `hook-timeout-to-timeout-ms` (:10383) | objectstack-ai#14478 | ruling B: a duration-shaped number key carries its unit in its name (or a unit-carrying value), every existing offender renamed under an ADR-0087 conversion, no grandfathered baseline (ruling 5518649320 + population ruling 5548763981) | hook key 'timeout' → 'timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged) | | `job-timeout-to-timeout-ms` (:10424) | objectstack-ai#14478 | as above (as above) | job key 'timeout' → 'timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged) | | `api-endpoint-cache-ttl-to-cache-ttl-seconds` (:11028) | objectstack-ai#14478 | as above (as above) | api endpoint key 'cacheTtl' → 'cacheTtlSeconds' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, seconds, is unchanged, and the key stays GET-only) | | `dashboard-refresh-interval-to-refresh-interval-seconds` (:11097) | objectstack-ai#14478 | as above (as above) | dashboard key 'refreshInterval' → 'refreshIntervalSeconds' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, seconds, is unchanged) | | `memory-persistence-auto-save-interval-to-ms` (:11458) | objectstack-ai#14478 | as above (as above) | memory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged) | | `turso-config-timeout-to-timeout-ms` (:11682) | objectstack-ai#14478 | as above (as above) | turso datasource key 'config.timeout' → 'config.timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged) | | `view-page-mount-removed` (:11772) | objectstack-ai#17063 | the maintainer chose to retire (「撤」) over finishing the objectui render half or parking it: the `page` member and its mount leave the spec under enforce-or-remove (card body (the maintainer ruling it records)) | list-view type 'page' and its `pageName` binding removed (retired rather than finished: the delegating render half was never built, so a page view fell through to the grid branch and drew an empty table; ADR-0049 enforce-or-remove) | | `list-view-sort-string-clause-to-array` (:11877) | objectstack-ai#17053, objectui#8221 | objectui#8221 option B: the legacy string `sort` is retired, one spelling platform-wide, the array. objectstack-ai#17053: the spec slot that produces those documents stops accepting the string objectui now refuses (triage 5620223775 (objectstack-ai#17053); ruling 5567944420 (objectui#8221)) | the bare string list-view `sort` clause becomes the `{ field, order }[]` array (one sort orthography platform-wide, the array: objectui already refuses the string, so the schema stops minting documents its own consumer refuses) | | `object-tenancy-organization-field-removed` (:12377) | objectstack-ai#19054 | take `organizationField` off the authorable surface; its one real use stays a platform-internal fact; citation dropped, the sentence already said it (card body (the maintainer ruling it records)) | object `tenancy.organizationField` removed (ADR-0049 — the stamp-only column declaration was authorable by every application and declared exactly once in the whole protocol, on the platform's own credential table; the divergence moves to a platform-internal table in @objectstack/metadata-core and stops being a knob) | | `view-item-owner-hidden-removed` (:12487) | objectstack-ai#20085 | retire both keys (ADR-0049 enforce-or-remove, zero pull) via the retirement playbook; citation dropped (triage direction 5826969296) | view item keys 'owner'/'hidden' removed (ADR-0049 — declared on the view item record and stored verbatim, read by nothing: no view switcher ever filtered on `hidden`, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone) | | `view-overlay-owner-hidden-removed` (:12631) | objectstack-ai#20230 | follow objectstack-ai#20085's disposition for the same key pair on the overlay door: the same retirement (triage direction 5856621469) | flattened view overlay keys 'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay door, retired the same way: declared, accepted by the write door and stored verbatim, read by nothing, so a `hidden: true` overlay hid no view and an `owner` scoped none) | | `page-component-filter-record-to-rule-array` (:13220) | objectui#6206, objectstack-ai#17321 | objectui#6206 option B: one filter orthography platform-wide, the rule array. objectstack-ai#17321 ruling B: a partial D2 conversion of what maps losslessly; combinator-carrying rows pass through untouched and are named as a TODO (flattening would silently change what a page selects) (ruling 5406409590 (objectui#6206); ruling 5644018752 (objectstack-ai#17321)) | a record-form or single-level AST filter at a converged rule-array door becomes the `[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a filter carrying `$and` / `$or` / `$not` or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which `os migrate meta --stored` lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects) | | `report-joined-chart-removed` (:13477) | objectstack-ai#20161 | retire, not build block charts: the joined arm refuses a container chart, the block key goes, a non-joined report keeps its live chart; citation dropped (triage direction 5852548444) | a joined report's 'chart' removed from its blocks and refused on the container (ADR-0049 enforce-or-remove: the joined renderer draws each block as a table and never read either, so the chart parsed and nothing was plotted; a non-joined report keeps its live 'chart') | | `form-layout-inline-grid-to-vertical` (:13742) | objectstack-ai#20221 | retire the `inline` / `grid` arms: multi-column already exists as `columns` and `inline` is not a record-form layout; citation dropped, the sentence already said it (triage direction 5855767378) | form 'layout' arms 'inline' and 'grid' rewritten to 'vertical' (ADR-0049 — no renderer ever gave either a behaviour of its own: every form presentation folded both to 'vertical'. Multi-column is 'columns', honoured under either layout, and is left untouched) | | `currency-config-precision-removed` (:13898) | objectstack-ai#19992 | remove `currencyConfig.precision`: a currency's decimal places are the currency's, not a setting; citation dropped, the sentence already said it (triage 5817146460 (ruling 乙 on objectstack-ai#19910 it executes)) | currency field key 'currencyConfig.precision' removed (ADR-0049 — no renderer or runtime ever read it: an amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. Its ISO 4217 contradiction check and the default `2` baked into parse output went with it; the field-level `precision` is a total digit count and is untouched) | | `permission-rls-tags-removed` (:14002) | objectstack-ai#20321 | RETIRE by the maintainer's criterion (no mainstream platform has the capability); citation dropped, the sentence already said it (triage verdict 5860425529) | RLS-policy key 'tags' removed (ADR-0049 — nothing ever read a policy's tags and no mainstream platform tags a row-level policy; dropping it changes no access decision) | | `flow-decision-mode-inclusive-explicit` (:14141) | objectstack-ai#15429 | align with mainstream engines: an edge-branched decision is exclusive (first match), and taking every true edge must be declared (`mode: 'inclusive'`); the migration writes it explicitly for existing nodes so authored behaviour is unchanged (ruling C narrows that promise to sources and artifacts) (ruling 5793803317; ruling C 5863827385) | edge-branched decision with two or more conditioned out-edges and no `mode`: `mode: 'inclusive'` written explicitly (the traversal became exclusive, first match in declaration order, as mainstream engines treat a decision, and taking every true edge must now be declared; the key keeps the every-true-edge behaviour those nodes had, and the author deletes it where the branches partition) | No site was left in place as unclear; `open_questions` is empty. ## Text only (A4) Stage 3's AST-skeleton plus string-text tool (`skeleton.cjs`, TypeScript 6.0.3; stage 4's copy with only its TypeScript load path changed to this worktree, md5 `3b10ec8a7e6284f19def54d35f001698` → `32b4630d7d0a532ee26239319269fe91`). Leg 1 compares an AST skeleton with every string's text masked (a `+` chain of string operands reads as one string, so re-wrapping is invisible); leg 2 compares the text of every string group, requiring each changed group to carry a tracker id before and none after, and every other group byte-identical. - `registry.ts`, base `e901c27449` vs the committed copy at `9c1d040145`: **1 of 1 SAME**, exit 0 — 62656 tokens both sides, 4955 string groups, 35 changed, every changed group carried an id before and carries none after; parse diagnostics 0/0. - Controls on scratch copies of the head file, each mutation counted on disk first (anchor hits 1, replacement present 1, anchor left 0): `renameFlowConfigAliases` renamed → DIFF exit 1; `!==` flipped to `===` → DIFF exit 1; one summary re-split into two `+` operands → SAME, 0 groups changed, exit 0; the `hook.timeout` surface string (never carried an id) changed → skeleton SAME, text leg VIOLATION, exit 1. No repo file was mutated for the controls. - The edits were applied by a script whose every anchor was asserted to hit exactly once, inside its own conversion's summary line span, and verified on disk after the write (36 anchors over 35 conversions). The conversion-table extractor reads 35 summaries changed, `toMajor` / `surface` / declaration unchanged on all 127, and 0 ids left in any summary. ## Pins and quotes (A6) No test asserts a summary, so no pin moves: nothing under `*.test.*` reads `.summary` off a conversion (the only summary reads are `spec-changes.ts` and `build-upgrade-guide.ts`), and every removed id-bearing fragment was searched across the repo. The hits are other files' own prose with their own citations (CHANGELOGs, `migrations/registry.ts` rationale, docblocks, `liveness/*.json` notes, test titles such as `permission.test.ts:278`, docs prose in `content/docs/permissions/*.mdx`), not quotes of a summary. `content/docs/**`: no quote of a changed summary. `skills/**`: none. ## Verification All builds and tests through `scripts/pm/os-verify-lock.sh` (slot `issue-20749-s5`), each `VERDICT command-exit 0`: - `pnpm --filter @objectstack/spec build`, then `check:generated` on the merged head `9a35e049e5`: "✓ All 15 generated artifacts are up to date". - The package `test` script (`vitest run --project local --maxWorkers=2`) on `9a35e049e5`: "Test Files 605 passed (605) / Tests 17904 passed | 1 todo (17905)". - `test:repo`: the 15 repo-project files that read the conversion registry, `spec-changes` or the guide, "Test Files 15 passed (15) / Tests 221 passed (221)". NOT MEASURED: `scripts/build-schemas-check-mode.test.ts` (88 cases at about 7 s each, over the foreground cap; it reads only conversion surfaces, which the proof shows unchanged) and the remaining repo-project files; reason: wall clock on a shared box. CI runs them. - `pnpm --filter @objectstack/spec run typecheck` on `9a35e049e5`: exit 0; "check:test-typecheck: OK — 52 file(s) / 246 error(s) / 135 pinned signature(s) held". - `pnpm turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*`: "Tasks: 71 successful, 71 total", for the gates that read built packages. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) at `9a35e049e5` derives 79 commands; each ran with its exit code written to disk before any pipe. Three first exited 3 (PREREQUISITE NOT MET: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` need built packages) and exit 0 after the build; the dist-reading gates were re-run after it too. `--ran`: "✓ dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED". - `pnpm check:doc-authoring` (self-test and run): exit 0, "17323 customer-facing string(s) across 1250 spec sources clean"; the sibling-package ledger holds its baseline (`packages/spec` sits outside it, so no ledger change). `pnpm check:nul-bytes`: exit 0, "no raw ASCII control bytes". - Changeset gates with this body as the `--event` payload: `check-changeset-no-major.mjs --base origin/main --event` exit 0 ("✓ LEVEL AXIS: this PR declares clause-② `no`", declaration line `Clause-②: no`); `check-partof-closing-keyword.mjs` with `PR_BODY` exit 0 ("no Part-of/closing-keyword contradiction"); `check-adr-0087-registration.mjs --base origin/main` exit 0 ("adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"); `check-empty-changeset.mjs --base origin/main` exit 0; `check-changeset-fixed.mjs` exit 0. - ESLint, a proven narrowing: `eslint --no-inline-config --format json` over the one changed TS file reads 1 file, 0 errors, 0 warnings; the population is read from ESLint's own config (`calculateConfigForFile` resolves it, `isPathIgnored` false); invariance: `eslint.config.mjs` enables no type-aware linting (`parserOptions.project` / `projectService` null for this file, its header at :327-328 says so), so a string-text edit cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. ## Acceptance notes - `origin/main` was merged once (`9a35e049e5`, five commits: objectstack-ai#21539, objectstack-ai#21473, objectstack-ai#21554, objectstack-ai#21556, objectstack-ai#21557; spec moved only in `contracts/approval-service.ts` TSDoc); spec rebuilt, `check:generated`, the spec test project, typecheck and the gate union re-ran on the merged head. No os-regen deferral was recorded. - Hot file: no open PR touches `conversions/registry.ts`, `spec-changes.json` or the upgrade guide (all nine open PRs' file lists read just before opening this one). - Census after this PR (stage 3's instrument at `9a35e049e5`): non-test 160 → 125 messages, 358 → 318 ids; class (b) is empty. Left for the later stages: class (c) conformance-case notes 58 messages / 68 ids (the `objectstack-ai#5322` selector and the `objectstack-ai#8934` name pin move with their tests), class (f) internal registry rationale 17 / 33, the test strings 1804 / 1920 in 425 files; `migrations/registry.ts` 50 / 217 stays with objectstack-ai#20234's stage 11. Word-form hits (an id spelled after "PR", "issue" and the like) stay 6, all outside this diff. - Excluded, untouched: `migrations/registry.ts`, comments anywhere, classes (c) and (f), test strings, the `.mjs` gate scripts. No gate is added or loosened. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ Co-authored-by: Claude <noreply@anthropic.com>
… metadata layer holds; layering can only narrow intake (objectstack-ai#21864) Fixes objectstack-ai#21835 Clause-②: yes (widening) Fixes a regression introduced after 17.6.0 (with objectstack-ai#21420); it should land before 17.7.0 is cut. ## What Per the rulings recorded on objectstack-ai#21835: a public form's withdrawal is a kill switch, layering can only narrow anonymous intake, a withdrawal closes the **same form** only, and only an **explicit** withdrawal counts. - **Anonymous doors (`GET /forms/:slug`, `POST /forms/:slug/submit`).** Both use one resolver and judge by the name of the view item they serve. When an organization is resolved, the env-wide view list beneath it is read as well. A form is served only when the env-wide item of the same name does not explicitly withdraw a form in the same slot (nested form, the same `formViews` key, or the flattened config) or with the same slug. Other views that share the public slug never close each other. - **What counts as a withdrawal.** A sharing that keeps its `publicLink` and sets `enabled: false` or `allowAnonymous: false`. Only an explicit false counts. Not a withdrawal: an absent switch, a sharing with no link (raw, or schema-parsed), a cleared link, a removed sharing block, or no body of the view at that layer. The public data collection docs page has a "Withdraw a public form" section with these rules. - **Write door (save and publish).** An org-scoped `view` save or draft promotion in the organization the doors read is refused with `403 NOT_OVERRIDABLE` when it would leave open a form the env-wide definition explicitly withdraws. It judges by the stored row: the body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package artifact), matched by slot or by slug. So renamed `formViews` keys, `form.name`, slot moves and listViews collision renames are the same form. It is also judged against the env-wide view list the way the doors read it, with container bodies expanded. Re-saving an overlay that was open before the withdrawal is refused. The message names both remedies. - **Package-shipped forms.** A package artifact is part of the env-wide definition, not a separate layer. A package artifact parsed by the stack schema (strict `defineStack`, the default) carries the schema's default `enabled: false`, so a shipped form that keeps its link without switching `enabled` on is an explicit withdrawal and fails closed. An artifact loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch, so an env-wide save may open a form the package ships closed. - **Known limit: packages and names.** A withdrawal of a view name closes that name in every package: when two packages ship a view of the same name, one package's withdrawal also closes the other package's form of that name. It may over-close, never under-close. Per-package precision is tracked in objectstack-ai#21934. A publish judges the draft it promotes under the same package key (the stated one, else the resolved draft row's own), so with two packages holding a draft of the same view in one organization, each draft is judged on its own publish. - **Intentional reversal.** The earlier behaviour in which an organization overlay re-published a form the package had withdrawn is reversed. A form with no env-wide word on it stays organization-publishable (objectstack-ai#21420), and the objectstack-ai#21473 anchors and objectstack-ai#21566 field allowlist are unchanged. - **Public surface:** `@objectstack/metadata-core` adds one export, `anonymousFormIntakeWithdrawnIn` (`minor`). `@objectstack/rest` and `@objectstack/metadata-protocol` are `patch`. - **Known limit (ruled to stay as is).** The doors match by served item name, and the write door runs only on an org-scoped save or publish. An organization overlay stored before the env-wide withdrawal, or restored by rollback or commit revert, can still be served if it keeps the form open under a different key or slot than the env-wide definition. Withdrawing the form in that overlay closes it. Stated in the changeset and the docs. ## Tests The first bullet is round 6, the second round 5, the third round 4; the bullets after them were measured at `e8778acb96` (round 2): - Round 6 at `7882eef683` (merged origin/main `9dce635337`, merge commit `46d08189a7`): metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27940 passed, 19 skipped; rest 260 files, 4912 passed, 326 skipped; objectql 375 files, 7469 passed (suites at `4d5f6c4e61`; the later commits touch docs, the changeset and three ported dogfood files only). Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green at `7882eef683`, reconciled with `dispatch-gates --ran`; `dispatch-gates --self-test` 1976 cases pass. The cross-package skip of round 5 is removed per the ruling, so a withdrawal of a view name closes it in every package again. Pins: another package's withdrawal of the same name closes this package's form too (metadata-core and the doors); with two packages shipping the same view name, a row-anchored rename by a package-bound org save is refused (metadata-protocol), with a withdrawn-save control. Ablation: the two edited sources set back to their round-5 blobs and rebuilt, markers proved in `dist/`: 1 red in each of metadata-protocol, metadata-core and rest; restored to HEAD (`git diff HEAD` empty), rebuilt, markers proved absent. - Round 5 at `d8657b5c19`: metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27938 passed, 19 skipped; rest 260 files, 4911 passed, 326 skipped; objectql 374 files, 7464 passed. Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green, reconciled with `dispatch-gates --ran`. New pins: two packages' drafts of one view in one organization are each judged on their own publish; one package's withdrawal of a name closes its own form (metadata-core and both doors; the cross-package half was inverted in round 6). Ablation: removing the package key from the publish gate's draft read turned the two-package pin red, and removing the package comparison turned the cross-package pin red; both restored to HEAD (`git diff HEAD` empty). - Round 4 at `79b847042d` (targeted): metadata-core `anonymous-form-intake.test.ts` 39/39, metadata-protocol `protocol.org-scoped-write-refused.test.ts` 41/41, rest `public-form-withdrawal` + `public-form-intake-availability` 43/43. Typecheck green for metadata-core and metadata-protocol. Docs and changeset gates green. New pins: a package parsed `false` is a withdrawal; an env-wide save opens a package-closed form. - `@objectstack/metadata-core`: 18 files, 394 passed. - `@objectstack/rest`: 260 files, 4906 passed, 326 skipped. - `@objectstack/metadata-protocol`: 214 files (3 skipped), 27752 passed, 19 skipped. - Typecheck green for all three and dogfood. - Dogfood (real showcase boot): the layered-withdrawal suite 5/5 (including the re-save refusal) and the five sibling public-form suites 20/20. - Coverage: two views sharing a slug do not close each other (one read, with and without an organization, and across layers); a cleared link is not a withdrawal; a parsed link-less sharing is not a withdrawal; re-saving an already-open overlay is refused; a container-shaped save is judged after expansion. - Ablation (source set back to the base blobs, packages rebuilt): 3 / 4 / 4 tests red across metadata-core / rest / metadata-protocol; restored to HEAD. - Gates: 92 of 95 derived run green; `check:skill-examples`, `check:dual-build-cjs-loads` and `check:type-check-debt` are NOT MEASURED locally (workspace-wide prerequisites) and left to CI. ## Acceptance notes - The known limit above (an overlay stored before the withdrawal, or restored by rollback or revert, with its form under a different key or slot) is accepted per the ruling on objectstack-ai#21835. No provenance or new protocol query was added. - Two installed apps publishing the same slug is a separate concern (slug collision), out of scope here. - A package artifact loaded without the stack schema's parse (`strict: false`, a hand-built manifest) is judged as written; giving every load path the schema's sharing defaults is left as a possible follow-up (see the round 5 report on objectstack-ai#21835). - An objectql test double now answers the publish gate's draft-row read (`protocol-publish-package-drafts.test.ts`); that is test-only. - Maintainer ruling, 2026-10-06: 「撤掉跨包那一改,合并」. The cross-package skip is removed; per-package precision is tracked in objectstack-ai#21934. - This branch carries three dogfood files ported unchanged from objectstack-ai#21935 (`packages/qa/dogfood/test/per-file-cwd.setup.ts`, `packages/qa/dogfood/test/per-file-cwd.global-setup.ts`, `packages/qa/dogfood/vitest.config.ts`) so the dispatch-gates self-test is green here; they merge away once objectstack-ai#21935 lands. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21468
Clause-②: no
Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring when the save is organization-scoped and the anonymous form doors cannot honour it; pinned both sides. Env-wide saves still withdraw and republish the form on every anonymous door, and single-posture deployments are unchanged.
Base: this branch was cut from the #21331 fix branch while #21420 was queued; #21420 has since merged and
origin/mainis merged into this branch (296d7342), so the diff againstmainis exactly the five files below.What changed
packages/metadata-protocol/src/protocol.ts: a new refusal on the metadata save door,anonymousFormIntakeOrgScopeRefusal. It runs insaveMetaItem(active and draft saves) and in the draft-to-active promotion. It applies to an organization-scopedviewwrite on a posture where the anonymous doors cannot honour it (read from thetenancyservice), and only when the write changes which slugs the form opens to anonymous intake, compared with the env-wide definition. It answers403 NOT_OVERRIDABLEwithorganizationIdanddocsset. The message says why and gives the remedy: save it env-wide. This reuses the code and status of the sibling org-scope refusal (orgScopedWriteRefusal). There is no new error code and no spec change.packages/metadata-protocol/src/anonymous-form-intake.ts:anonymousFormIntakeSlugs, which projects a view body to the slugs it opens to anonymous intake. It reads the same three candidate shapes and the sameallowAnonymous+publicLinkpredicate as the anonymous doors.defaultOrgId()is the admin's organization.Tests
Run at
b5e4b515(this branch's head when this was written).packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts: a new describe block of 7 cases on the existing stub engine. The walled posture is modelled withdefaultOrgIdanswering null. Four refusal cases: org-scoped withdrawal (envelope, and no org row written), org-scoped draft, org-scoped publish of an env-wide-private form, and promotion of a legacy org-scoped draft. Three controls: an org-scoped edit that leaves the sharing alone, the env-wide withdrawal, and the single posture (defaultOrgIdequal to the org). Result: 22/22 pass.packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts: a real boot withmultiTenant: 'posture-only'(isolated,defaultOrgId()answers null) of a fixture form bound to an object declaredtenancy: { enabled: false }. Precondition: the published form serves and accepts (200 / 201, the row lands). In an organization, the withdrawal answers 403NOT_OVERRIDABLEand names the env-wide save and nothing is saved. An org-scoped label edit saves (200,org=). An env-wide withdrawal makes both doors answer404 FORM_NOT_FOUND, and no row lands. An env-wide republish restores both doors. Result: 5/5 pass.showcase-public-form-withdrawal.dogfood.test.ts) andshowcase-public-form.dogfood.test.ts/public-form-read-back-masking.dogfood.test.tsare still green.pnpm --filter @objectstack/metadata-protocol test: 201 files passed, 3 skipped; 2990 tests passed, 19 skipped.typecheckpasses for@objectstack/metadata-protocoland@objectstack/dogfood.scripts/ablation-replace.mjswith a restore trap. Then a rebuild, andablation-dist-preflightconfirmed the marker in 2 dist files. Unit: 4 red / 18 green (the four refusal cases). Dogfood: 1 red / 4 green (the organization-scoped refusal case). Restore: blob equals HEAD,git diff HEADis empty, rebuilt, marker absent from all 24 dist files.dispatch-gates.mjs, 70): 68 exit 0.check-plugin-teardown-shape --self-testfirst exited 3 (shallow clone, pinned fixture unreachable); after fetching that commit it exited 0.check:dual-build-cjs-loadsis NOT MEASURED (exit 3,PREREQUISITE NOT MET: unrelated packages have nodist/), declared to CI.pnpm lintwas not run locally and is declared to CI.Acceptance notes
tenancyservice registered, the refusal does not apply. Such a composition has no posture to judge and no session to carry an organization over HTTP.ERR_SYSTEM_WRITE_ORGANIZATION_REQUIREDfrom the engine). That is why the walled pin uses a tenancy-disabled fixture object instead of the showcase contact form. This PR does not change it; it is reported to the dispatching seat.Generated by Claude Code