Repository navigation
fix(metadata-protocol): org overlay withdrawal and publish gate follow-ups (package identity, judged draft, lock key, row anchor) - #21962
Conversation
…every name, so the form doors judge every package's withdrawal The view list read serves one item per package that ships a view name (ADR-0048) whether or not a view row is stored; only a name a stored container's expansion writes is upserted by name. The env-wide list is the layer the anonymous form doors judge a withdrawal against, so a package-less organization overlay stored before one package's withdrawal is compared against every package's body of the name. Pin: a package-less organization overlay under two packages shipping its view name is served once per package, each copy stamped with that package; after either package withdraws the name env-wide (first or second in registry order), the env-wide list holds the withdrawal, the doors serve no copy, and a re-save of the overlay is refused. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…dged The publish gate reads the draft to judge it, and the promotion read the draft row again to write it. The promotion is now handed the judged draft's hash (null when the gate found no draft): SysMetadataRepository.promoteDraft takes an optional expectedDraftHash and refuses a draft row with another hash, or any draft row where the caller judged none, with a ConflictError before anything is written. The protocol answers it as 409 METADATA_CONFLICT with its own wording. Pin: a draft saved after the gate read, or where the gate judged none, is not promoted and the conflict answers; with no save in between the judged draft is promoted and its draft row drained. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…y it resolved The publish path resolves one package key for the draft it promotes (the caller's stated binding, else the draft row's own) and reads and promotes the draft under it. Its ADR-0010 lock lookup took only the package the request stated. The key is now resolved first and threaded into the lock lookup too. The authoring gate's narrowing to the stated package is left as it is. Pin: with two packages' env-wide rows of one view both locked, a publish that states no package is refused with the lock of the draft row's own package; stating a package consults that package's lock. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…that package's env-wide definition The organization-scoped save check judges a view overlay against the env-wide body of the row it is keyed by. That anchor is now resolved per package, the way the list read resolves each package's item: the package's own env-wide row, else the package-less env-wide row (which stands in for every package), else that package's artifact. It no longer falls back to one artifact per name, the first in registry order, and one package's stored row no longer stands for every package's artifact of the name. The "never under-closes" wording on the withdrawal judgement's docblock and on the public data collection page is narrowed to match what holds: a withdrawal of a name may over-close across packages; both checks read every package's env-wide definition, except that the anonymous form endpoints read one package's expansion of each form name when several packages' stored view containers expand it. Pin: with the withdrawing package not first in registry order, a package-less or package-bound org save that renames the form is refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…g-overlay-publish-gate
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 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 cac27bf66ae2c9454c2d8ff2e31bdcc1eb5ec708 && git checkout cac27bf66ae2c9454c2d8ff2e31bdcc1eb5ec708
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a22177ae786b1adf6cc22ce596c47bddd254b4a 3eea8f0995e4348d93f8deac466698fe852b17aa && git checkout -B drift-repro 2a22177ae786b1adf6cc22ce596c47bddd254b4a && git merge --no-ff 3eea8f0995e4348d93f8deac466698fe852b17aa
node scripts/docs-audit/affected-docs.mjs --json 2a22177ae786b1adf6cc22ce596c47bddd254b4a
|
…dable store as the lock read did Item 3 moved the publish path's draft-key read ahead of the lock check. An unreadable store is now answered at that read the way the lock read answers it: an unprovisioned sys_metadata holds no draft, and any other failure is the 503 SERVICE_UNAVAILABLE the lock read raised before, never the driver's own error. Pin: a publish that states no package, over a store that cannot be read, answers 503 and promotes nothing. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21934 body; triage grade 6007709051; addendum 6008978556 (item 4 in scope); unlock 6009643967; claim 6010039236 (revised in place to ① Derived judgmentsKill-switch invariant across the four items: right in code. Each change refuses a superset of what base refused at the place the card names, and opens nothing base closed. The only wrong derived judgment is in the wording deliverable the addendum asked for (last bullet of this section). Item 1, the view list keeps one item per package (
Item 2, a publish promotes only the draft its gate judged.
Item 3, the lock lookup takes the key the gate resolved.
Item 4, the save check anchors each package's row on that package's env-wide definition (
Wording deliverable (addendum 6008978556: "narrow that sentence to match"): WRONG, blocking. Three places state the endpoint exception as "when two packages each have an environment-wide copy of the same view container saved" ( Check-runs on this head: right. Test Core (all 6 shards), TypeScript Type Check, the four Type Check gates, Lint and Repo Gates, Check Changeset, Build Core, Build Docs, Check Documentation Links, Dogfood Regression Gate (3 shards), Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard and the PR-context guards all conclude success; Console Pin Gate and the opt-in packed-tarball smoke are skipped. Their conclusions are the gate verdicts; nothing was re-run. ② Semver level
③ Boundary flagsDev report 6011280365, each flag answered:
Implemented-by: VERDICT: FAIL Blocking defect, one: the narrowed "Known limit" wording ( Adopted by REWORK, patch round 1 (narrow). The code of all four items stands. Only the residual's wording changes, in the three places the record names, plus the PR body:
The exception, stated as the record directs: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Option A stays with #21967. Its reach is corrected there in this round. Generated by Claude Code |
…nv-wide container copy, and shipped withdrawals too The narrowed "Known limit: packages and names" wording stated the anonymous endpoints' exception as two packages each saving an env-wide copy of one view container. One saved copy is enough: where a package's env-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. The organization-scoped save check still judges every package's env-wide definition of the name, and a withdrawal of a view name still closes that name in every package, so it may over-close. Corrected in the public data collection docs page, this card's save-check changeset and the anonymousFormIntakeWithdrawnIn docblock. Reading each package's expansion separately is tracked in #21967. No code change. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Round 2, after the FAIL record on ① Derived judgmentsWhat moved: wording only. Items 1 to 4, code (unchanged since The blocking defect of round 1: cured. The three sentences now state the endpoint exception at the reach the code has:
The round's new remedy sentence ("To close such a form at the endpoints, withdraw it in every saved environment-wide copy of that container as well", Readers and accept sets: unchanged from round 1. No code moved, so the reader enumeration and the "no accept set narrows" judgment of the round-1 record hold. Check-runs on this head: right. Test Core (6 shards), TypeScript Type Check, the four Type Check gates, Lint and Repo Gates, Check Changeset, Build Core, Build Docs, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate (3 shards), Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard and the PR-context guards conclude success. Console Pin Gate and the opt-in packed-tarball smoke are skipped; the duplicated Auto Label and Check PR Size entries are re-runs on the body patch, one skipped and one success each. None failed. Their conclusions are the gate verdicts; nothing was re-run. ② Semver level
③ Boundary flagsDev round report 6012226132:
Round-1 flags, closed out:
Implemented-by: VERDICT: PASS Adopted by ACCEPT (seat review). The seat read the round's diff against
Generated by Claude Code |
…curity follow-up) (objectstack-ai#21964) Fixes objectstack-ai#21932 Clause-②: no ## What This PR adds one checklist item, `access-security.public-form-withdrawal-layers`, to `docs/qa/platform-checklist/areas/access-security.json`. It sits right after `access-security.public-form-intake`. Its fields are rev 1, `since: v17.7`, P1, surface `api`. No other file changes. This delivers the last two rows of objectstack-ai#21932. The first five rows and both re-checks landed in PR objectstack-ai#21943. ### The objectstack-ai#21835 / PR objectstack-ai#21864 row: public-form withdrawal layering The item is written against what PR objectstack-ai#21864 landed on `main`. Its merge, `3c7785d4ab`, is an ancestor of this branch's base `01e0f71a`. Each rule on the card maps to a clause: | Card rule | Where in the item | Oracle | Code anchor | |---|---|---|---| | An env-wide withdrawal is not re-opened by an org overlay | acceptance[0]: both doors answer 404 `FORM_NOT_FOUND` and no row lands. acceptance[1]: an org-scoped save that would leave the form open answers 403 `NOT_OVERRIDABLE` | api | `rest-server.ts#registerFormEndpoints`, `anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn`, `protocol.ts#anonymousFormIntakeReopenRefusal` | | Only an explicit false withdraws | acceptance[2]: with an absent `allowAnonymous`, or no `publicLink`, env-wide, the org save that opens the form is accepted and both doors serve it | api | `anonymous-form-intake.ts#anonymousFormExplicitWithdrawals`. Premise: `protocol.ts#projectStorableViewBody` | | A package's shipped false withdraws | acceptance[4] | test | `anonymousFormExplicitWithdrawals`. Pins: `protocol.org-scoped-write-refused.test.ts` ('single: a package-shipped form') and `anonymous-form-intake.test.ts` | | The env-wide definition may open a package-closed form | acceptance[5] | test | `protocol.ts#envWideRawViewRows`, with the same protocol pin | | The ruled known limit is recorded as a known gap | `fixtures.knownGaps[0]`, plus a negative saying it is not a FAIL | none | The doors match by served item name. The save check runs only from `saveMetaItem` and the draft promotion, never from `rollbackMetaItem` or `revertCommit` | acceptance[3] is the control pair, which the dogfood also pins: - An organization can always withdraw the form for itself. - A form open at both layers is served, and its row lands in the organization. `automated.ref` leads with `packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts`. That dogfood covers acceptance[0], [1] and [3] end to end. The ref also names the rest, metadata-protocol and metadata-core unit pins. The steps drive the stock showcase form, `showcase_inquiry.contact` at `/forms/contact-us`. The admin saves it at two scopes: env-wide, and in the Default Organization the doors read. Both doors are probed anonymously. ### Where the code is narrower than the card's wording Where they differ, the item follows the code: - **A package's shipped false.** This holds only for an artifact the stack schema parsed (strict `defineStack`, the default). There the schema default `enabled: false` counts as an explicit false. An artifact loaded unparsed (`strict: false`, or a hand-built manifest) is judged as written, so a switch it omits is absent and withdraws nothing. acceptance[4] says so. - **Only an explicit false.** The false must sit on a sharing that keeps a non-empty `publicLink`. A sharing with no link withdraws nothing, even with both switches false. acceptance[2] says so. - **A second documented limit.** `main` carries "Known limit: packages and names" besides the ruled one. Cases where two packages ship the same view name are outside this item's fixture. The item points at that docs section as it reads at the run's commit, rather than restating it. ### The objectstack-ai#21867 / PR objectstack-ai#21928 row Confirmed on `main` with no change. The item is `automation.paused-run-trigger-record-masked` in `docs/qa/platform-checklist/areas/automation.json`, at rev 1, `status: active`. Its `automated.ref` is `packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts`, which is on disk. PR objectstack-ai#21928 merged as `1f0469655f`, an ancestor of this branch's base. ### Overlap with objectstack-ai#21934 objectstack-ai#21934 is not addressed here. Its PR objectstack-ai#21962 was an open, unmerged draft when this PR was opened, so the item is written against `main` as it stands. - **The ruled known limit does not depend on objectstack-ai#21934.** PR objectstack-ai#21962 leaves the docs page's "Known limit." paragraph and the doors' name-based identity unchanged. - **The multi-package line holds either way.** PR objectstack-ai#21962 rewrites the "Known limit: packages and names" section. This item points at that section as it reads at the run's commit and names objectstack-ai#21934, so its line stays true whether or not PR objectstack-ai#21962 lands. - **The `envWideRawViewRows` note holds either way.** It is scoped to "a form one package ships", which is true before and after PR objectstack-ai#21962. That PR keeps the symbol and resolves it per package. - **No shared files.** This PR touches only the checklist JSON. It changes neither `content/docs/ui/public-data-collection.mdx` nor any package source. ## Tests All results are at head `2d51effa`. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 13 commands, the same 13 the dispatch named. All 13 exited 0, with each exit code captured before any pipe. The `--ran` reconciliation reads 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN. - **Checklist gate.** `pnpm check:platform-checklist` answered `OK — 15 areas, 275 items (271 active, 2 planned)` with 690/700 symbol anchors resolved. At the base `01e0f71a` it read 274 items and 676/686. All 14 new anchors resolve, and the 10 that do not are the named objectstack-ai#16898 residual. - **Formula gate.** `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET, because formula and lint were unbuilt), so that run measured nothing. I built both under `os-verify-lock` (VERDICT command-exit 0), and the gate then exited 0. - **Not owed.** No package source changed, so no build, test, typecheck or lint is owed. The cited test-case names were read from the test files on `main`. The pins themselves were not re-run here; they ran in CI on PR objectstack-ai#21864 and PR objectstack-ai#21928. ## Acceptance notes - **`coverage.json` is untouched.** The claim's file surface is `areas/*.json`, and the `view` kind is already mapped. Mapping the new item to `view` is optional, and is left to whoever next owns `coverage.json`. - **A stale clause in the sibling item.** `access-security.public-form-intake` clause 7 says "republishing restores service" but does not name the scope of the republish. With layering, republishing in an organization over an env-wide withdrawal is refused with a 403. The new item covers that case. The old item is unchanged, with no revision bump, to keep this PR to the card's rows. - **No changeset.** The diff touches only `docs/qa/platform-checklist/areas/access-security.json`, which no published package ships: the root package is private, and no package `files` entry names `docs/qa`. `skip-changeset` applies. --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… decision in words instead of a tracker number (stage 25) (objectstack-ai#21975) Part of objectstack-ai#20749 Clause-②: no Stage 25 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the second and last name-ordered `api/` group: the 13 id-bearing test files directly under `packages/spec/src/api/` from `plugin-rest-api.handler-status-retirement.test.ts` to `zod-issues-to-fields.test.ts`. Those files carried 89 messages and 95 tracker ids, citing 43 records. All 95 now either state what their record decided, in words (form D), or are dropped where the title already says it. No needle sits in this group. Text only: no assertion, identifier, test count or code comment changes, and no file is renamed. With this stage, `api/` carries no tracker id in a test string. ## Census at the base (`5a22eb5619`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages 10 to 24 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. The worktree was cut from `origin/main` at `5a22eb5619`, the claim's base and stage 24's landing. Both instruments read **371 messages / 392 ids in 84 files**, the seat's reading and stage 24's head reading. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `system/` | 34 | 154 / 167 | 128 / 138 | 26 / 29 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `api/` (this PR: all 13 files) | 13 | 89 / 95 | 86 / 92 | 3 / 3 | | `ui/` | 5 | 7 / 7 | 0 | 7 / 7 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **84** | **371 / 392** | **331 / 349** | **40 / 43** | The group reads **89 messages / 95 ids in 13 files**, the seat's figures file for file: | file (under `api/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `plugin-rest-api.handler-status-retirement.test.ts` | 4 / 4 | 3 / 3 | 1 / 1 | | `plugin-rest-api.schema-refs.test.ts` | 2 / 2 | 2 / 2 | 0 | | `plugin-rest-api.test.ts` | 2 / 2 | 2 / 2 | 0 | | `protocol.test.ts` | 46 / 50 | 46 / 50 | 0 | | `registry-retirement.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 | | `rest-api-config-dead-keys-retirement.test.ts` | 2 / 2 | 2 / 2 | 0 | | `rest-server.test.ts` | 19 / 19 | 18 / 18 | 1 / 1 | | `router.test.ts` | 1 / 1 | 1 / 1 | 0 | | `sortability.test.ts` | 3 / 4 | 3 / 4 | 0 | | `storage.test.ts` | 2 / 2 | 2 / 2 | 0 | | `validate-data.test.ts` | 3 / 3 | 3 / 3 | 0 | | `websocket.test.ts` | 1 / 1 | 1 / 1 | 0 | | `zod-issues-to-fields.test.ts` | 2 / 3 | 2 / 3 | 0 | | **13 files** | **89 / 95** | **86 / 92** | **3 / 3** | Five more test files sit in the same name range and carry no id (`query-adapter.test.ts`, `realtime-shared.test.ts`, `realtime.test.ts`, `retired-error-codes.test.ts`, `versioning.test.ts`). The three "other" strings are expect failure messages, rewritten and declared to the text-only tool: `plugin-rest-api.handler-status-retirement.test.ts:179` and `rest-server.test.ts:768` (template literals) and `registry-retirement.test.ts:89` (one leaf of a `+` chain). - **Controls.** Lit: `ui/notification.test.ts` (1 id) and `system/book.test.ts` (2 ids), outside the group, read the same at the base and at the head. Dark: `protocol.test.ts` reads 0 at the head while 65 of its lines still carry a number, every one of them a comment. Planted in a scratch tree: an id put into a `storage.test.ts` title reads 1 / 1 (`title:it`), and an id put into a `sortability.test.ts` comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same as the gate pattern in all 13 files at the base, and 0 in all 13 at the head. - **At the head:** 282 messages / 297 ids in 71 files. The 13 files read 0 / 0, `api/` leaves the table, and no other file moved. ## How the area was chosen `api/` has no subdirectory, so it is taken in name-ordered file groups near the ~100-id bound, the rule stages 20 to 24 used. Stage 24's cut named this group at 95 ids, and this census reads 95, so no re-cut was needed. `protocol.test.ts` (50 ids) fits one PR and one text-only proof, so it is not split. **Named for the next stages** (cut from the head census, 282 / 297): - **`system/`**, 167 ids in 34 files (one of them in `system/constants/`), two stages: - **first group:** `auth-config.test.ts` through `metadata-form-declared-rows.pin.test.ts`, 18 files, 91 messages / 97 ids (`i18n-resolver.test.ts` alone 53 / 56); - **second group:** `metadata-form-zod-reconciliation.test.ts` through `worker.test.ts`, 16 files, 63 / 70. Its first file carries 17 "other" strings, its ledger `why` entries. - The files directly in `src/`, 120, one stage. - The needles: the three docblock needles, the kept `ui/component-props-unknown-members.pin.test.ts:322` and stage 22's two. One stage, with an at-tier review. The four colour literals stay, as stage 21 decided. ## What each id became - **10 literals (11 ids)** now state a decision in words. - **12 literals (16 ids)** get their subject back in words, where the number stood for a thing. - **67 literals (68 ids)** drop a number the title already explains. Every cited record was fetched with all its comments through REST, and its decision was read from its ruling, ACCEPT and landing comments: a keyword digest of every record, and full reads wherever the new words carry a decision. 43 records are cited: 36 answer 200 and 7 answer 404. Two more were read for context: objectstack-ai#14478, whose ruling B objectstack-ai#15677 executes, and PR objectstack-ai#11426, objectstack-ai#11006's landing. The seven that answer 404 were read from what landed, through the commits endpoint (this checkout is shallow), each commit found through the CHANGELOG entry or the commit list of `protocol.test.ts`: - **objectstack-ai#6037**, from `18189983dd` (objectstack-ai#6474): `DataProtocol.validateData` asks the write path for its verdict and persists nothing, objectstack-ai#4633 ruling D; - **objectstack-ai#6239**, from `f549a0d4ad` (objectstack-ai#6526): `ViewProtocol`'s five viewId-addressed methods and ten schemas are retired; - **objectstack-ai#6361**, from `90bbf25107` (objectstack-ai#6866): the notification-list `cursor` is tombstoned on both halves (maintainer ruling 2026-08-07, option A); - **objectstack-ai#9740**, from `11b779e0f9` (objectstack-ai#9773): `MetadataProtocol.getMetaItemLayered` is declared, and the dead `'overlay'` `lockSource` arm is dropped; - **objectstack-ai#9741**, from `2a29caa532` (objectstack-ai#9804): `previewDrafts` / `state` are declared where the implementation enforces them, and `environmentId` is recorded as transport-level. Its changeset (`packages/spec/CHANGELOG.md:31798`) names it "maintainer ruling 2026-08-18", and `cccbe51bf7` cites "the objectstack-ai#9741 ruling"; - **objectstack-ai#11006**, from `cccbe51bf7` (objectstack-ai#11426): `publishMetaItem` is declared as an optional member with `PublishMetaItemRequest` (maintainer ruling 2026-08-22, option B); - **objectstack-ai#14691**, from `b3a63d32c9` (objectstack-ai#14868): the ten inert `RestServerConfig` keys the liveness ledger recorded as `dead` are retired. **The same-id titles stage 24 listed in this group:** - **`[objectstack-ai#5672]` x2** (`protocol.test.ts:508`, `:526`): objectstack-ai#5672's maintainer ruling A (`5199159328`): one closed capability vocabulary, emitted in full by both discovery producers, with an absent capability `enabled: false` rather than a missing key. `:508` now reads "strips a capability key outside the closed vocabulary". The old verb was "rejects", but the body pins the opposite: the parse stays green and the key does not survive it. `:526` now reads "… (ruled: an absent capability is `enabled: false`, not a missing key)". - **`(objectstack-ai#12038)` x5** (`:2575` to `:2686`): these five "declares the … body" describes are the describe-only transcriptions that the five-part ruling's implementation plan names (`5434804846`). None of them pins a lettered sub-ruling, so no letter is named; the title already says the decision, and only the number goes. - **`(objectstack-ai#12038 1C)`** (`:2710`): now "GetPublishedMetaItemResponseSchema stays opaque (ruled: no shape frozen against the current type registry)", ruling 1C's own reason. Its children pin the `unknown` body. - **`(objectstack-ai#19543, door ③)`** (`:2726`): door ③ is the AI-conversation list, which the schema name already names, and "declares the next-page signal" is that door's spec half (letter A, re-derivation `5825819437`). Only the number and the door label go. - **`(objectstack-ai#15677)`** in `plugin-rest-api.test.ts:694` and `websocket.test.ts:712`: now "… durations carry their unit in the key name", objectstack-ai#14478's ruling B (`5518649320`, population ruling `5548763981`), which objectstack-ai#15677 executes for `api/`. In `router.test.ts:565` the title already shows the rename (`RouteDefinition.timeout → timeoutMs`), so only the number goes. **Stated in words:** | record | literal (under `api/`) | now reads | the decision | |:--|:--|:--|:--| | objectstack-ai#14478 via objectstack-ai#15677 | `plugin-rest-api.test.ts:694`, `websocket.test.ts:712` | "… durations carry their unit in the key name" | Ruling B: a `z.number()` duration key carries its unit in its name; the old spellings are `retiredKey()` tombstones. | | objectstack-ai#5672 | `protocol.test.ts:508` | "strips a capability key outside the closed vocabulary" | Ruling A (2026-08-06): one closed vocabulary. | | objectstack-ai#5672 | `protocol.test.ts:526` | "rejects a capability map that is missing part of the vocabulary (ruled: an absent capability is `enabled: false`, not a missing key)" | Ruling A: both producers emit the whole vocabulary. | | objectstack-ai#9406 | `protocol.test.ts:1313` | "probes is opaque BY DECLARATION (ruled: modeled only once a consumer needs a field): …" | Maintainer ruling 2026-08-18 (`5322875103`): `probes` gets a deliberately opaque passthrough, upgraded to a modeled schema only when a consumer needs a field of it. | | objectstack-ai#9343 | `protocol.test.ts:1383` | "PublishPackageDraftsResponseSchema published[].advisories (ruled: advisory findings ride each published element)" | Maintainer ruling 2026-08-17 (`5321046016`): `advisories` rides each `published[]` element, with no parallel top-level map. | | objectstack-ai#9741 | `protocol.test.ts:1739` | "environmentId stays OUT of the meta-read request shape — transport-level by decision, not omission" | The 2026-08-18 ruling, as landed in `2a29caa532`: `environmentId` is the transport-level multi-kernel routing key. | | objectstack-ai#12038 | `protocol.test.ts:2710` | "GetPublishedMetaItemResponseSchema stays opaque (ruled: no shape frozen against the current type registry)" | Ruling 1C (`5434804846`): a thin envelope with the body opaque, no union frozen against today's type registry. | | objectstack-ai#6037 | `validate-data.test.ts:25` | "ValidateDataRequest — asks the write path for its verdict instead of predicting it" | What landed in `18189983dd`: the dry run stops predicting the write's verdict and asks for it. | | objectstack-ai#6037 | `validate-data.test.ts:57` | "ValidateDataResponse — the verdict the write path would reach, persisting nothing" | The same commit: `validateData` reports the write path's verdict on candidate rows and persists nothing. | **Subject back in words** (12 literals): - "zero holders after objectstack-ai#13823" becomes "zero holders after its retirement", and "[objectstack-ai#13823] ADR-0087 registration" becomes "handlerStatus retirement — ADR-0087 registration", the form of the repo's other retirement registration describes (objectstack-ai#13823 ruled remove, `5494755488`); - "the routes wired in objectstack-ai#3899" becomes "the routes wired to the request-schema gate", the gate the file's header names; - "(objectstack-ai#13155 — carries objectstack-ai#5745 to the third verb)" becomes "(carries the declared = returned discipline to the third verb)", the discipline objectstack-ai#7294 and objectstack-ai#13155 name objectstack-ai#5745 for; - "(objectstack-ai#4717 — objectstack-ai#4463 D3 on the response)" and "(objectstack-ai#9176 — objectstack-ai#4463 D3 on the publish door)" become "(advisory findings ride the 2xx response)" and "(advisory findings ride the 2xx on the publish door too)": objectstack-ai#4463's D3 sends gating findings to 422 and lets advisory findings ride the 2xx; - "the objectstack-ai#9612-gate class" becomes "the package-closure publish-gate class": objectstack-ai#9612's gate judges a publish against the written package's closure; - "objectstack-ai#10235 the objectstack-ai#7865 anchor category" becomes "the unprovisioned injected-anchor category", the platform anchors injected into an external object whose storage the platform does not provision (objectstack-ai#7865, ruling B); - the two "pre-objectstack-ai#3689" storage shapes become shapes "from before the shared success envelope"; - "the objectstack-ai#4052 non-repeat" becomes "the non-repeat of the retired `validateOnly` dry-run flag"; - "every objectstack-ai#8055-shaped fixture" becomes "every malformed-flow-body fixture". **Dropped where already stated** (67 literals, 68 ids). A number goes only where the title already says its decision. Examples: the two `[objectstack-ai#13823]` describes and the twelve `[objectstack-ai#14691]` / `(objectstack-ai#14691)` retirement titles ("REJECTS `patterns` with the retirement prescription — …", "the tombstones reject one key each, not the config — …"); `[objectstack-ai#11983]` x3, `[objectstack-ai#4579]` x2, `[objectstack-ai#4939]`, `[objectstack-ai#6361]`, `[objectstack-ai#20294]` and `objectstack-ai#3899 —`; the `objectstack-ai#10235` prefixes on "resolveObjectSortability — the closed category set" and "wire validity — …"; the five "transport-level by the objectstack-ai#9741 ruling" titles, which now read "transport-level by ruling"; the parenthesized `(objectstack-ai#5745 — …)`, `(objectstack-ai#7294 — …)`, `(objectstack-ai#9406 — …)`, `(objectstack-ai#10524 — …)` x2, `(objectstack-ai#9726 — …)`, `(objectstack-ai#9741 — …)` and `(objectstack-ai#4717 — …)` pairs, which keep their words; and the tails `(objectstack-ai#6239)`, `(objectstack-ai#4286)`, `(objectstack-ai#9740)`, `(objectstack-ai#11006)` x2, `(objectstack-ai#11678)` x3, `(objectstack-ai#9426)`, `(objectstack-ai#12005)` x3, `(objectstack-ai#11679)` x2, `(objectstack-ai#12004)` x2, `(objectstack-ai#3718)`, `(objectstack-ai#4572)`, `(objectstack-ai#4579)`, `(objectstack-ai#20294)`, `(objectstack-ai#8124/objectstack-ai#8055)`, the five `(objectstack-ai#12038)` and the `(objectstack-ai#4738, …)` aside in one expect message. The 404 numbers among them (objectstack-ai#6239, objectstack-ai#6361, objectstack-ai#9740, objectstack-ai#9741, objectstack-ai#11006, objectstack-ai#14691) go only where the title already states what landed. **No file is renamed.** ## Readers - **Needles:** none. The three declared strings are assertion failure messages (the second argument of `expect`), none is an expected value, and no title or message in the group is matched against a source docblock or another file's text. The one self-read in the group, `rest-api-config-dead-keys-retirement.test.ts:519`, reads its own file for the id-free describe title "tree-scoped absence", which this PR does not touch. - **Test-name filters:** none. No tracked script, workflow or package config passes `-t` / `--testNamePattern` to vitest; the one vitest `-t` hit is a README example under `packages/qa/dogfood` filtering its own fixture. - **Snapshots:** none. No `__snapshots__` directory is tracked under `packages/spec`, and none of the 13 files calls a snapshot matcher. - **Projects:** `rest-api-config-dead-keys-retirement.test.ts` is in the `repo` project (`packages/spec/vitest.repo-tests.json:31`); the other 12 run in `local`. The base-versus-head run below takes both projects. - **By substring:** every old literal, its id-bearing fragment and a window around each id (270 needles) was searched with `git grep` at the base, across the tracked tree outside its own file. No gate, doc, filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test reads one. The 6 hits are sibling test titles: the two `(objectstack-ai#15677)` describes in this group hit each other (both rewritten here), `client/src/client.test.ts:1134` shares "query.distinct (objectstack-ai#4286)", and `metadata-protocol/src/protocol.validate-data.test.ts:102` shares "the objectstack-ai#4052 non-repeat". ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. This stage declares the three expect-message lines named above. - **Result:** 13 of 13 files SAME on all three legs, with the per-file counts predicted in writing before any edit. - **Totals:** 89 changed string leaves in 89 literals: 86 titles and 3 declared. The diff's `+` and `-` lines are exactly the 89 planned lines as multisets, and every file keeps its line count. - **Controls (14 of 14 as predicted on the first run, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; an `it.each` row given an id VIOLATION; an undeclared expect message changed VIOLATION; a title re-split into a `+` chain DIFF; a declared expect message reverted to base SAME; a declared template expect message given a new id VIOLATION; a declared `+`-chain leaf given a new id VIOLATION; a template-literal title given a new id VIOLATION. - **Templates and tables:** no `.each` title and no `$name` placeholder changes. The two template literals change only their text after the `${…}` span. **Test counts:** the 13 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 509 tests in 13 files, all passed, with the same count and status sequence per file in 13 of 13. 250 full test names change, and each changed name equals the base name with the planned replacements applied: 0 mismatches. No full name repeats on either side, and no head name carries `#` plus digits (250 base names did). `router.test.ts:565` writes its arrow as a `→` escape; the plan's anchor there starts after the escape, so the comparison tool, which reads escapes literally, met none, and vitest prints "RouteDefinition.timeout → timeoutMs …" on both sides. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 13 touched files are in it, and no `*.test.ts` at all. The controls `src/api/protocol.zod.ts`, `src/api/rest-server.zod.ts` and `dist/index.mjs` are in it. - In the built `dist/`, two new phrases and an old one each read in 0 files. The control `Unrecognized key` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `c63eba0adf`) - `pnpm turbo run build` over all packages: 71 / 71, through the shared verify lock (`VERDICT command-exit 0`). - `@objectstack/spec`: - `vitest run --project local`: 619 files, 18480 passed, 1 todo. - `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 13 group files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date, against the `dist/` the build above wrote. - **Gates:** `dispatch-gates --commands` derived 79 families: stage 24's 80 without `check:error-code-casing`, whose named sources this diff does not touch. All 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The same 79 derive from `origin/main` `230e4944b0` with this diff applied. The five roster families marked as sharing a directory with this diff (`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) each exit 0. - **ESLint, a proven narrowing:** `--no-inline-config` over the 13 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 13 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 178 changed lines (+89 / -89). - A control-byte scan over the 13 changed files finds none. ## `main` since the base Re-fetched just before this PR opened, `origin/main` was six commits past the base (`c9761cd2fb`: objectstack-ai#21966, objectstack-ai#21951, objectstack-ai#21963, objectstack-ai#21969, objectstack-ai#21965, objectstack-ai#21962). They touch 28 files, none of the 13 and none under `packages/spec/src/api/`, so `main` was not merged. The two `packages/spec/src` files they change (`data/datasource-credential-redaction.ts` and its test) read 0 / 0 in the census at `c9761cd2fb`: the one id they add is a code comment. `git merge-tree` onto `c9761cd2fb` is clean, and none of the 4 open PRs touches any of the 13 files. ## Acceptance notes - **Same-id test titles in this card's later stages** go with those stages: `system/book.test.ts:413` (`(objectstack-ai#12038)`). - **Same-id test titles in other packages** stay: 97 lines in 15 packages (`runtime` 25, `objectql` 13, `lint` 12, `metadata-protocol` 12, `rest` 12, `client` 8, `metadata-core` 4, `qa/dogfood` 2, `service-automation` 2, `service-storage` 2, and one each in `examples/app-crm`, `examples/app-showcase`, `driver-sql`, `plugin-sharing` and `types`), each package's share under the objectstack-ai#20513 lane children. - **Code comments with live ids** remain in these files and their sources, among them the `* objectstack-ai#3899 —` header in `plugin-rest-api.schema-refs.test.ts`, the `* objectstack-ai#8124 —` header in `zod-issues-to-fields.test.ts`, the `// [objectstack-ai#5672] This fixture used to lead with …` comment above `protocol.test.ts:508`, and the `/** [objectstack-ai#20294] … */` docblock in `rest-api-config-dead-keys-retirement.test.ts`. Code comments are not this card's share. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21934
Clause-②: yes (widening)
Four LOW/INFO follow-ups to the public-form withdrawal work of #21864, one commit and one pin each, so any item can be dropped at review without the others. Each change is described in the card's public terms. All four land in
@objectstack/metadata-protocol; the only other source edit is a docblock in@objectstack/metadata-core, plus the narrowed sentence on the public data collection docs page.Item 1: package identity of a served org overlay (
21f75eb892)Measured. The judgement the anonymous form doors and the organization-scoped save check share (
anonymousFormIntakeWithdrawnIn,packages/metadata-core/src/anonymous-form-intake.ts:329) compares no package, and the doors' lookup (findPublicFormView,packages/rest/src/rest-server.ts:10735) reads no_packageId. So the package stamp the list merge puts on a package-less org overlay (packages/metadata-protocol/src/protocol.ts:2166and:9077) cannot by itself make a withdrawal miss it. The item's outcome was still reachable onmain(a3bd157730), through the same list merge rather than through a package comparison: the env-wide view list the doors judge against could hold only one package's item of a view name that two packages ship. Measured through the protocol's real list reads and the doors' own verdict: an overlay stored package-less before the withdrawal stayed open after one package's withdrawal and closed after the other's.Changed.
protocol.ts, the list merge's view branch: only a name a stored view container's expansion writes is upserted by name. Every other name keeps one item per package that ships it (ADR-0048), as the list already served it while no view row was stored. Neither of the card's two directions applies (nothing compares packages, so marking stamped copies or reading the org row's ownpackage_idchanges no verdict); the fix is at the producer of the layer the doors read. No door code changes.Pin (
protocol.org-scoped-write-refused.test.ts, "a package-less organization overlay, two packages shipping its view name"): the organization read serves the overlay once per package, each copy stamped with that package; for the package first and the package second in registry order, after it withdraws the name env-wide the env-wide list holds the withdrawal beside the other package's body, the doors serve no copy of the overlay, and a re-save of the overlay is refused.Item 2: the publish gate and the promotion are separate reads (
d1365db627)Measured (H2 confirmed).
promoteDraftForPublishreads the draft throughrepo.getto judge it (protocol.ts:21623at base), andSysMetadataRepository.promoteDraftreads the draft row again with its ownfindOne(sys-metadata-repository.ts:969at base). Nothing tied the two reads together, so a draft saved between them, or a draft that appeared where the gate found none, was promoted without being judged.Changed. A publish promotes only the draft its gate judged.
SysMetadataRepository.promoteDrafttakes an optionalexpectedDraftHash(string | null): when stated, the draft row it reads must carry that hash (withnull, no draft row may exist), otherwise it throws aConflictErrorsubclass before anything is written.promoteDraftForPublishpasses the judged draft's hash (ornull), and answers the conflict as409 METADATA_CONFLICTwith its own wording (publish again to judge and promote the current draft). This coverspublishMetaItemand each promotion ofpublishPackageDrafts.A route without a new public option exists and was not taken: the existing
deriveActiveBodycallback receives the body the promotion read and could compare it with the judged body and throw. It turns a derivation hook into a guard and compares bodies instead of the stored hash the card's direction names, so the explicit option was preferred. That option is the Clause-② widening below.Pin ("a publish promotes only the draft its gate judged"): a draft saved after the gate read, and a draft saved where the gate judged none, are not promoted and the conflict answers; control: with no save in between, the judged draft is promoted and its draft row drained.
Item 3: the lock lookup uses the request's package (
114ed6393c, follow-up7c30229b43)Measured (H3 confirmed). The publish path passed
request.packageIdtolockWriteRefusal(protocol.ts:21583at base), while the gate resolves its draft key a few lines later: the stated binding, else the resolved draft row's ownpackage_id(draftKey). Since the lock resolution reads every row and every shipping package in scope and takes the strictest lock, the package in the address decides whose lock prose the refusal carries, not whether it refuses: the INFO grade.Changed. The draft key is resolved before the lock check and threaded into the lock lookup. The authoring-rule narrowing to the stated package is left exactly as it is. Follow-up
7c30229b43: with the draft-key read moved above the lock check, a store that cannot be read is answered at that read as the lock read answered it before (an unprovisionedsys_metadataholds no draft; any other failure is503 SERVICE_UNAVAILABLE, never the driver's own error).Pin ("a publish consults the lock of the package key it resolved"): with two packages' env-wide rows of one view both locked, a publish that states no package is refused with the lock of the draft row's own package; control: stating a package consults that package's lock. Follow-up pin ("a publish that states no package, over a store that cannot be read"): it answers 503 and promotes nothing.
Item 4: the save check's row anchor across packages (
1e271aaae1)Measured (H4 confirmed).
envWideRawViewRows(protocol.ts:16092at base) returned every stored env-wide row of the name when any existed (so one package's row hid every package's artifact), and otherwise fell back tolookupArtifactItem(type, name)with no package key (the first package in registry order).Changed. The save check anchors each package's row on that package's env-wide definition: the package's own env-wide row, else the package-less env-wide row (which stands in for every package, as in the list merge), else that package's artifact, read through
shippedArtifactsOf.Wording. The "never under-closes" sentence is narrowed in the
anonymousFormIntakeWithdrawnIndocblock and in the "Known limit: packages and names" paragraph ofcontent/docs/ui/public-data-collection.mdx(declared todomain:devxon #6023). The released changeset of #21864 is not edited; this card's changeset states the narrowing. As corrected in3eea8f0995after the contract review, the narrowed text keeps "a withdrawal of a view name still closes that name in every package, so it may over-close" and the statement that the organization-scoped save check judges every package's environment-wide definition of the name, and states the endpoints' one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Reading each package's expansion separately is tracked in #21967. The narrowed text assumes items 1 and 4 both land; if item 1 is dropped, the endpoint exception in that paragraph widens to every view name two packages ship.Pin ("the save check anchors each package's row on that package's env-wide definition"): with the withdrawing package not first in registry order, a package-less and a package-bound org save that renames the form are refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package.
Clause-②
Measured against the built entry declarations, base
a3bd157730against head5297072f13, comments stripped before the diff:@objectstack/metadata-protocoldist/index.d.ts:SysMetadataRepository.promoteDraft(ref: MetaRef, opts: {...})gainsexpectedDraftHash?: string | null;(head line 9883). An optional input field: a widening. The only other declaration difference is comment placement.@objectstack/metadata-coredist/index.d.ts: the declaration ofanonymousFormIntakeWithdrawnIn(parameterslayer,view,candidate, returningboolean) is byte-identical (base line 21311, head line 21316); only its docblock changed.Unchanged at the final head
3eea8f0995: the later commits change a method body, a docblock, the docs page and a changeset, and the rebuilt declarations are identical with comments stripped. SoClause-②: yes (widening), and item 2's changeset isminor. The other three changesets arepatch.@objectstack/metadata-corecarries no changeset: its edit is a comment.Tests
Final head
3eea8f0995(origin/main76fec88b16merged at5297072f13). The last commit,3eea8f0995, corrects wording only (the docs page, one changeset, a docblock);packages/metadata-protocol/srcis byte-identical at7c30229b43, where its suites ran:@objectstack/metadata-protocolat7c30229b43: typecheck green (tsc --noEmit; the edited test file is in the program, counted with--listFiles), full suite 218 files passed, 3 skipped; 27984 tests passed, 19 skipped.@objectstack/metadata-coreat3eea8f0995: typecheck green (both programs); 18 files, 411 tests passed.@objectstack/objectql(a consumer of the protocol, against itsdistbuilt at5297072f13; the later code commit only changes an outage path): 378 files, 7507 tests passed.scripts/ablation-replace.mjs, each from a committed head, each item's code set back to its base shape (anchor hit once, blob changed on disk; the test imports the source, so nodistleg), the item's pin run, then restored and proved (blob equal to HEAD,git diff HEADempty):1e271aaae1(itsprotocol.tsblob is the one at5297072f13): item 1: 5 red, 2 green (the two write-door re-save cases, which item 1 does not touch); item 2: 2 red, 1 green (the control); item 3: 1 red, 1 green (the control); item 4: 3 red, 2 green (the two controls);7c30229b43: item 3's follow-up, its store-failure classification removed: its pin 1 red.3eea8f0995, derived bynode scripts/pm/dispatch-gates.mjs --commands(no paths; the same 93 commands as at5297072f13and7c30229b43): 92 run green, among themcheck:doc-authoring,check:docs-audit-scope, the docs-auditcheck-affected-docsandcheck-drift-comment,check:docs,check:nul-bytes, and the changeset gates (check-changeset-no-majorwith this PR's payload,check-empty-changeset,check-adr-0087-registration,check:changeset-gate-self-tests). 1 NOT MEASURED:pnpm check:dual-build-cjs-loads(PREREQUISITE NOT MET: it loads every workspace package'sdist, 32 of which were not built in this worktree; it was green at7c30229b43, whose code this head keeps). Reconciled:dispatch-gates --rananswers "93 derived famil(ies) accounted for — 92 run, 1 NOT-MEASURED". The artifact-roster block (53) is green, the PR-context gates run against this PR. The four symbol-anchor sweeps (check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchors,check:adr-anchors) are green.Acceptance notes
domain:clion [PM seat] domain:cli — 🟢 os-elon-musk · session_01BmsuLyUeuG5CNpZFMH1jzS #6024).@objectstack/metadata-protocol(not exported from its entry); callers see aConflictError.Generated by Claude Code