Repository navigation
fix(metadata-protocol): a package-scoped list slot serves the package-less row getMetaItem naming the package serves - #21871
Conversation
…-less row getMetaItem naming the package serves The package-less rows in scope enter each merge of a package-scoped list as stand-ins: they serve a slot the package seats, by the shared candidate order, and never seat one, so the list's membership is unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…tem naming the package, and its membership Seven pins: the generated table over every subset and order of five rows, with and without an organization and the package's artifact; named row orders; membership; the MetadataService layer; the draft preview; the view container expansion; the lock family. Plus the patch changeset. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…oped-list-fallback
…e engine-double ledger Written by `node scripts/check-engine-double-contract.mjs --write` (1 row added, 0 lost). Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…oped-list-fallback
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 ce1363e58706940e93a0faca2435422362d63b0f && git checkout ce1363e58706940e93a0faca2435422362d63b0f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9f9510f25e6aa65aa61ce3effb42706fabcab92e b5492dce3ecd1614a131f1f9afdea680f646b792 && git checkout -B drift-repro 9f9510f25e6aa65aa61ce3effb42706fabcab92e && git merge --no-ff b5492dce3ecd1614a131f1f9afdea680f646b792
node scripts/docs-audit/affected-docs.mjs --json 9f9510f25e6aa65aa61ce3effb42706fabcab92e |
ACCEPT (seat review) — PR #21871 at head
|
… a writable runtime package updates its own row instead of forking it (objectstack-ai#21881) Fixes objectstack-ai#21861 Clause-②: no ## What this changes Setup saves a permission set through the data door (`PATCH /api/v1/data/sys_permission_set/:id`). The security plugin's write-through (`createPermissionSetWriteThrough`, `packages/plugins/plugin-security/src/permission-set-projection.ts`) redirects that edit into the metadata store. Its update leg merged the patch into the stored body and called `saveMetaItem` with no `packageId`. A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save that names no package targets the package-less row (`SysMetadataRepository.put`: an omitted `packageId` is the unbound row). So for a set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=PKG`), whose only stored row is bound to that package, the edit answered `200` and minted a second, package-less active row carrying the edit. The package-bound row stayed as it was. The update leg now reads the package binding of the row it edits and passes it as `packageId`: - The row is the `overlay` layer of the layered envelope the leg already holds. `effectiveBodyForRow` takes that layer as the base the patch merges into. - Its binding is read from the metadata door's own single-item read, `protocol.getMetaItem({ type: 'permission', name })`. That read serves the row through the same served-row resolution as the layered read (`findServedOverlayRow`, no package), and it states the row's `package_id` on the item as `_packageId` ("surface the persisted software-package binding", `protocol.ts`). The binding is not taken from the patch, from the projected record, or from a registry item. - No `overlay` layer means no stored row. Then there is nothing to fork, and the call is unchanged. - A package-less row carries no `_packageId`, so its save still names no package. - A code-shipped set is refused by the lock before this read (`assertPermissionSetNotPackageDeclared` runs in the pre-pass). The lock and its refusal do not move. - A failed binding read is not caught. Guessing the binding would choose which row the save lands in. One file of production code, one new non-exported helper. No `packages/spec` or `packages/metadata-protocol` edit, no new error code, no new exported symbol, and no second classifier: every target reaching the save has already been classified `org` by `classifyPackagedPermissionSet`, so the binding read only tells a package-bound `org` set from a package-less one. ## The dispatch hypotheses, measured - **H1 (the update leg saves package-less and forks): confirmed.** On `origin/main` `88a39c09` (this branch's base, which carries PR objectstack-ai#21857), through the new door pin's own steps, the runtime-package set's first data-door edit answered `200`. Its active rows went from one, bound to the package, to two: `{ package_id: null, description: "Edited at the data door" }` and `{ package_id: "com.dogfood.bind21861", description: undefined }`. The update leg is the `saveMetaItem` call at `:1374` on this base. The `:1297` named in the dispatch is the insert leg's call. - **H2 (where the binding can be read): measured before any edit.** The projected `sys_permission_set` record reads `managed_by: "admin"`, `package_id: null`, so the projection does not carry the binding. The layered read's `overlay` layer has no `_packageId`, and its envelope `packageId` is null: the code layer is the projection echo. `getMetaItem`'s `item._packageId` is `com.dogfood.bind21861`, both through `GET /api/v1/meta/permission/:name` and through `protocol.getMetaItem` in-process. That existing reader is the one used here. - **H3 (only the runtime-package set needs the binding): held.** It is pinned at the door: the package-less set saves package-less, and the code-shipped `showcase_contributor` is still refused with `403 NOT_OVERRIDABLE` with its row count unchanged. - **H4 (other legs): none forks the same way, so none is changed.** Details are under Acceptance notes. ## Pins - **Door pin** (new file `packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts`, showcase). Every case counts the active `sys_metadata` rows for the name under both type spellings and in every scope, before and after the edit: - a runtime-package set: `200`, exactly one row before and after, still bound to the package and carrying the edit. Edited twice, before and after the list read (`GET /meta/permission`) that every Studio page load issues; - a package-less set: `200`, one package-less row before and after, carrying the edit; - a code-shipped set: `403` with `code: NOT_OVERRIDABLE`, and the stored rows are unchanged. - **Unit** (`permission-set-projection.test.ts`). The suite's existing protocol double now keys a stored row by its package, as the repository does, and serves the row's binding on `getMetaItem`. No new engine double, so the engine-double ledger does not move. Four cases: package-bound, package-less, a filtered edit spanning one of each (each saved into its own row), and the code-shipped refusal (`code` and `status` asserted, no save, the binding never read). Each case asserts the row count before and after. ## Ablation (committed fix first, then mutated, then restored) At `32048afa30`, `scripts/ablation-replace.mjs` replaced the anchor `item: body, ...packageArg, ...actorArg });` (1 hit, then 0) with `item: body, ...actorArg });` (0 hits, then 1). This puts the package-less save back. Blob `e77bd871` became `4186f983`. The run was wrapped in a `trap` restore on EXIT, INT and TERM. - Rebuild: `pnpm turbo run build --filter=@objectstack/plugin-security` exited 1, at DTS only (`TS6133: 'packageArg' is declared but its value is never read`). The JS was already emitted, and `node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security "item: body, ...actorArg }"` found the marker in 2 built files (`dist/index.js`, `dist/index.mjs`). The ablation was live in the artifact the door pin consumes. - Unit: 2 failed, 72 passed. The first red is the row count: `expected [ …(2) ]`, the package-bound row plus `{ package_id: null, description: "edited at the data door" }`. - Door pin: 2 failed, 2 passed. The runtime-package set's two cases went red with two active rows, `{ package_id: "com.dogfood.bind21861", description: undefined }` and `{ package_id: null, description: "Edited at the data door" }`. The package-less and code-shipped cases stayed green, as they should. - Restore: blob equal to HEAD (`e77bd871`), `git diff HEAD` empty, `git status --porcelain` empty across the whole tree. After the rebuild, `--absent` found the marker in 0 of 6 built files. Unit 74 of 74 passed, door pin 4 of 4 passed. ## Tests (all at `32048afa30`) - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 167 files, 3604 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security run typecheck`, which includes `check:test-typecheck` over `tsconfig.test.json`: pass. `pnpm --filter @objectstack/dogfood run typecheck`: pass. - After a fresh closure rebuild (`turbo run build --filter=@objectstack/dogfood^...`, 63 tasks): dist preflight shows the fix's spelling present and the ablation marker absent. `permission-set-projection.test.ts` 74 of 74 passed. The door pins `permission-set-write-through-package-binding` and `permission-set-lock-row-provenance` (PR objectstack-ai#21857's) passed 18 of 18. - Narrowed lint: `eslint --no-inline-config --format json` over the three changed `.ts` files reported 3 files, 0 errors, 0 warnings. The population is the config's `**/*.{ts,…}` globs, and the fourth path is the `.md` changeset. The config never enables type-aware linting (no `parserOptions.project`), so the diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. - `integration`-tier tests are not relevant (no `packages/cli` path). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands at `32048afa30`. All 70 were run, and each exit code was recorded. 69 exited 0 on the first pass. `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: 8 packages outside the dogfood closure had no `dist/`). Those packages were built (all cache hits), and the gate exited 0 on the rerun. `--ran` reports: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. The derivation names 8 commands the dispatch-time list did not have, all changeset-related because this PR adds a changeset: `check-adr-0087-registration` (both), `check-empty-changeset` (both), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:objectui-changeset` and `check:pm-changeset-deadline-census`. ## Acceptance notes - **The write-through's other legs (H4), read rather than changed.** - **Insert:** the duplicate-name check refuses whenever the name has a record. A stored row with no record is only transient, because the projector is awaited on every save and boot reconciliation creates a record for every overlay. No door was measured to fork through it. - **Restore:** it re-authors package-less after a delete. A runtime-package set's delete removes its bound row, because `deleteMetaItem` with no package matches the row in any package. So a restore would leave one package-less row: a lost binding, not two rows. That needs a different fix, since nothing holds the binding once the row is gone. The leg is also unreachable on a real engine, because no trash state exists (the leg's own comment). Carrier: none. - **Reconcile backfill:** only names with no metadata presence, so there is nothing to fork. - **Delete:** matches any package, so no fork. - **Degraded path, unchanged.** When the layered pre-read fails and the lock answers from the engine registry alone, the leg has no envelope. It reads no binding and saves package-less, as before. That needs a transient read failure followed by a successful save. Carrier: none. - **A stored row bound to a read-only package (a booted code package, or a `system`/`cloud`-scoped one) with no artifact.** For such a row, the save would now meet the protocol's own read-only-base refusal, which `PUT /meta?package=` meets too, instead of forking silently. No door mints such a row: `saveMetaItem`'s D1 check and `SysMetadataRepository.assertAllowed` refuse that binding at write time. Hence `Clause-②: no`. The accepted set does not move, and the edit still answers `200`. - **Base.** `origin/main` has moved past `88a39c09`. The incoming commits include objectstack-ai#21871, a `metadata-protocol` change to the package-scoped list merge. It does not touch `getMetaItem`'s single-item stamp or `findServedOverlayRow`, which this fix reads. objectstack-ai#21860 (PR objectstack-ai#21873) has not landed, so main was not merged, per the dispatch. CI's merge ref covers the joint state. - **Docs.** No `content/docs` sentence describes a data-door edit forking a package-bound set, so none is made false. The older `permission-sets.mdx` drift about overlays of packaged sets is the one PR objectstack-ai#21857's review already noted. It is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…decision in words instead of a tracker number (stage 19) (objectstack-ai#21882) Part of objectstack-ai#20749 Clause-②: no Stage 19 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 last `data/` group: the name-ordered test files directly under `packages/spec/src/data/` from `query-transport.test.ts` to `validation.test.ts`, plus `data/driver/`. Those 18 files carried 82 messages and 86 tracker ids, citing 39 records. Every one of those ids now either states what its record decided, in words (form D), or is dropped where the title already says it. Text only: no assertion, identifier, test count or code comment changes. After this PR `data/` carries no tracker id in a test string. ## Census at the base (`124533b388`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the copies stages 10 to 18 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 base is `124533b388`, the claim's base and stage 18's landing. Both instruments read **942 messages / 994 ids in 208 files**, the seat's reading and stage 18's head reading. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `ui/` | 84 | 396 / 419 | 378 / 401 | 18 / 18 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `data/` (this PR) | 18 | 82 / 86 | 81 / 85 | 1 / 1 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **208** | **942 / 994** | **885 / 936** | **57 / 58** | The group reads **82 messages / 86 ids in 18 files**, the seat's figures, file for file: 11 files directly under `data/` (34 ids) and 7 in `data/driver/` (52 ids). | file (under `data/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `driver/config-registry.test.ts` | 3 / 3 | 3 / 3 | 0 | | `driver/driver-credential-refusal.test.ts` | 27 / 30 | 27 / 30 | 0 | | `driver/driver-placeholder-refusal.test.ts` | 9 / 10 | 9 / 10 | 0 | | `driver/memory.test.ts` | 1 / 1 | 1 / 1 | 0 | | `driver/pg-url-grammar.test.ts` | 2 / 2 | 2 / 2 | 0 | | `driver/postgres.test.ts` | 3 / 3 | 3 / 3 | 0 | | `driver/turso.test.ts` | 3 / 3 | 3 / 3 | 0 | | `query-transport.test.ts` | 5 / 5 | 5 / 5 | 0 | | `query.test.ts` | 11 / 11 | 10 / 10 | 1 / 1 | | `record-surface.test.ts` | 2 / 2 | 2 / 2 | 0 | | `search-fields.test.ts` | 6 / 6 | 6 / 6 | 0 | | `secret-mask.test.ts` | 1 / 1 | 1 / 1 | 0 | | `seed.test.ts` | 1 / 1 | 1 / 1 | 0 | | `tree-reference-self-only.test.ts` | 1 / 1 | 1 / 1 | 0 | | `unique-scope-message.test.ts` | 1 / 1 | 1 / 1 | 0 | | `unique-scope.test.ts` | 1 / 1 | 1 / 1 | 0 | | `validation-boundary-description.test.ts` | 4 / 4 | 4 / 4 | 0 | | `validation.test.ts` | 1 / 1 | 1 / 1 | 0 | | **18 files** | **82 / 86** | **81 / 85** | **1 / 1** | The one "other" string is the `expect` message at `query.test.ts:202`, declared to the text-only tool. Three more test files sit in the same name range and carry no id: `seed-loader`, `type-compat` and `driver/mongo`. They are not touched. - **Controls.** Lit: `ui/dashboard.test.ts`, outside the group, reads 21 ids at the head as at the base. Dark: `search-fields.test.ts` and `driver/driver-credential-refusal.test.ts` read 0 at the head while 16 and 29 of their comment lines still carry a number. Planted in scratch copies of head files: an id put into a `seed.test.ts` title reads 1 / 1, and an id put into a `secret-mask.test.ts` comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same totals as the gate pattern in all 18 files at the base, and 0 in all 18 at the head. - **At the head:** 860 messages / 908 ids in 190 files. The 18 files read 0 / 0, `data/` reads 0 / 0, and no other file moved. ## How the area was chosen `data/` has been taken in name-ordered file groups near the ~100-id bound. Stage 18's re-cut named this group at 86 ids, the whole remainder of `data/`, and this census reads 86, so the rule needed no re-cut. **Named for the next stages** (re-cut from the head census, 860 / 908): - `ui/` 419 ids in 84 files, about four stages. It has no subdirectory, so the same name-ordered rule applies. The first group near 100 runs from `action-confirm-params-guard.test.ts` to `component-record-block-field-security.test.ts`: 32 files, 97 messages / 102 ids. It holds three "other" strings: two `expect` messages in `action-requires-confirmation-docblock.pin.test.ts` (`:168`, `:175`) and one in `component-props-unknown-members.pin.test.ts` (`:322`). That stage should check whether any of them is a needle. - `api/` 201, two stages. `system/` 165, two. The files directly in `src/`, 120, one. - The three docblock needles (`ai/build-progress.test.ts:236`, `:237`, `contracts/approval-service.test.ts:274`), one stage with their docblocks. ## What each id became - **16 literals (17 ids)** now state a decision in words. - **12 literals (15 ids)** get their subject back in words, where the number stood for a thing, such as "the objectstack-ai#9041 arm". - **54 literals (54 ids)** drop a number the title already explains. Every cited record was read with its comments through REST. 33 answer 200. Six answer 404, and each decision was read from what landed, the landing commit and its CHANGELOG entry: - objectstack-ai#6345 (`e2798fab76`, one driver vocabulary: `mongo` → `mongodb`, turso gets a config contract); - objectstack-ai#8495 (`4bfe1a539d`, `${…}` refused in memory `persistence.path` / `persistence.key` at publish); - objectstack-ai#8696 (`90a12fb18d`, a bound secret injected on the mongodb DSN branch); - objectstack-ai#8876 (`d634e665b0`, `urlUserinfoUsername`, the username half of the userinfo grammar); - objectstack-ai#9040 (`24206416a7`, a credential in the mongo `options` passthrough refused at publish); - objectstack-ai#9041 (`d491625c17`, bound `credentialsRef` with a user-less mongo `config.url` refused at publish). **Stated in words:** | record(s) | literal (under `data/`) | now reads | |:--|:--|:--| | objectstack-ai#4410 | `driver/config-registry.test.ts:71` | "DatasourceSchema × driver config — parsed against the contract its driver ships". Ruled enforce: `data/driver/` became the one contract, and `DatasourceSchema` parses `config` against it. | | objectstack-ai#6969 | `driver/config-registry.test.ts:178` | "… — what a boot flag may offer, derived from the one driver table". The CLI's hand-written `--database-driver` lists were replaced by a set derived from the shared table. | | objectstack-ai#8155 | `driver/driver-credential-refusal.test.ts:851` | "accepts the blessed shape byte-identically: bare-username URL + bound secret, what the stored-credential remedy prescribes". Ruled shape (b): the operator moves the credential to the secret store; a URL-embedded one is told to keep a bare `user@host` and bind the secret. | | objectstack-ai#8336, objectstack-ai#8495 | `driver/driver-placeholder-refusal.test.ts:234` | "memory `initialData` stays UNJUDGED — the deliberate seed-data exclusion holds". The placeholder refusal excluded seed data; objectstack-ai#8495 extended it to `persistence.path` / `persistence.key` only. | | objectstack-ai#9091 | `driver/pg-url-grammar.test.ts:39` | "pg-url-grammar server twin — still asks the parser `pg` itself runs". A postgres `config.url` that `pg` cannot open is refused at publish. | | objectstack-ai#11072 | `driver/pg-url-grammar.test.ts:55` | "pg-url-grammar browser twin — degrades to the shape-only checks, with no `pg` parse". Ruled option A: a `browser` export condition whose bundle drops the `pg` parse. | | objectstack-ai#16066 | `query-transport.test.ts:38` | "§1 what the transport declares — a flattened spelling of the AST, never a second semantics". Ruled: the wire dialect is declared in spec as a 1:1 alias table; `QuerySchema` itself does not grow. | | objectstack-ai#4721 | `query.test.ts:202` (`expect` message) | "this parsed to `order: asc` before the sort node was closed". Ruled (a)+(b): `SortNodeSchema` strict, with `direction` → `order` as a prescription. | | objectstack-ai#2604 | `record-surface.test.ts:59` | "deriveRecordFlowSurface — viewing a record may route, a create or edit task is always an overlay". Decision D1: view is a state and may route; create and edit are tasks and never route. | | objectstack-ai#2578 | `record-surface.test.ts:64` | "view keeps the field-count detail surface verbatim: …". The detail surface is derived from the field count: heavy → page, light → drawer. | | objectstack-ai#4254 | `search-fields.test.ts:120` | "the ingress gate that refuses an unsearchable `$searchFields` entry no longer admits `$searchFields=id`". Ruled: an unknown or unsearchable `searchFields` entry answers 400, never a wider scan. | | objectstack-ai#6674 | `search-fields.test.ts:257` | "a virtual field declared in searchableFields — not admitted, since no driver stores it". Promoted on the objectstack-ai#4254 precedent: a declared entry that can never match is not admitted. | | objectstack-ai#7572 | `secret-mask.test.ts:40` | "SECRET_MASK — the credential read mask (ADR-0100), declared once for every masked read". Option A: one declaration in spec, re-exported by objectql and aliased by service-settings. | | objectstack-ai#8323 | `unique-scope-message.test.ts:137` | "… the accept/reject line does not move, and bare `true` keeps its meaning". Ruled: no unannounced reinterpretation of bare `unique: true`. | | objectstack-ai#3696 | `unique-scope.test.ts:19` | "UniqueScope (ADR-0120) — bare `true` on a field is unique per organization; global uniqueness must be said". Field-level `true` became a composite per-organization index, and `'global'` the explicit opt-in. | | objectstack-ai#3184 | `validation.test.ts:1120` | "ValidationRule - events property — insert and update only; a delete guard is a beforeDelete hook". Decision: trim `delete` from the enum, not enforce it. | **Subject back in words** (12 literals, 15 ids). In `driver/driver-credential-refusal.test.ts` and `driver/driver-placeholder-refusal.test.ts`, numbers that named a sibling refusal now name it: "objectstack-ai#9041" becomes "the user-less URL arm" or "the user-less URL refusal", "objectstack-ai#9147's arm" becomes "the no-username arm", "objectstack-ai#9040" becomes "the options-passthrough credential refusal" or "the passthrough read path", "objectstack-ai#8082" becomes "the URL userinfo refusal" or "check", "the inverted objectstack-ai#8078 pin" becomes "the pin that once recorded them as accepted, inverted", "objectstack-ai#8336" in "the deep judgement" becomes "the deep placeholder judgement", and "the honest objectstack-ai#4410 boundary" becomes "the honest boundary of config validation". **Dropped where already stated** (54 literals, 54 ids). A number goes only where the title already says its decision. Examples: "QueryAST.joins — REMOVED (objectstack-ai#4286)" and its three siblings; "FieldNode — the nested-select object form is REMOVED (objectstack-ai#4196)"; "AggregationNode.distinct — REMOVED (objectstack-ai#6815, ADR-0049)", which keeps `(ADR-0049)`; the four `objectstack-ai#13879 —` describe prefixes, each of which states its semantics; "`$driver` — inline credential refusal (objectstack-ai#7990)" and the other refusal-family describes; "TursoConfigSchema refuses what the turso driver refuses (objectstack-ai#19977)". `(ADR-0049)`, `(ADR-0100)` and `(ADR-0120)` stay: they cite decision records by number, not tracker ids. ## Readers - **Test-name filters:** none. No tracked script, workflow or config passes `-t` / `--testNamePattern`. - **Snapshots:** none. No `__snapshots__` directory is tracked under `packages/spec`, and none of the 18 files calls a snapshot matcher. - **Projects:** none of the 18 files is listed in `packages/spec/vitest.repo-tests.json`; all 18 run in the `local` project. - **By substring:** every old literal, plus a window around each id (245 needles), was searched across the tracked tree outside its own file. No gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 11 hits are: - 7 code comments citing "the objectstack-ai#4254 ingress gate" (`lint` `validate-searchable-fields.ts` / `.test.ts`, `validate-react-page-props.test.ts`, `metadata-protocol` `protocol.search-title-namefield.test.ts`, `spec` `data/search-fields.ts:198`); - two release-owned CHANGELOG lines; - a sibling title in this card's `ui/` stage (`ui/dashboard.test.ts:717`); - a sibling title in `metadata-protocol` (`protocol.query-transport-dialect.test.ts:176`). None reads a spec test title. - **The files by name:** outside CHANGELOGs, `data/query.test.ts` is named by `test-typecheck-debt.json`, which keys on the file and on error signatures, not on a title, and by two code comments; `data/secret-mask.test.ts` by one code comment. ## 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 one line, `query.test.ts:202`. - **Result:** 18 of 18 files SAME on all three legs, with the per-file counts predicted in writing before the run. - **Totals:** 82 changed string leaves in 82 literals: 81 titles and 1 declared. The diff's `+` and `-` lines are exactly the 82 planned lines, and every file keeps its line count. - **Controls (10 of 10 as predicted, 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. - **`.each` titles:** five `describe.each` titles lose only their trailing id. No `$driver` / `$name` placeholder, row or table value changes. **Test counts:** the 18 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 737 tests, all passed, with the same count and status sequence per file in 18 of 18. 476 full test names change, and each equals the base name with the planned replacements applied (0 mismatches). No full name repeats on either side. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 18 touched files are in it, and no `*.test.ts` at all. The controls `src/data/query.zod.ts` and `dist/index.mjs` are in it. - In the built `dist/`, a new phrase and an old literal each read in 0 files. The control `Unrecognized key` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `8788de7a4a`) - `pnpm turbo run build` over all packages: 71 / 71. - `@objectstack/spec`: - `vitest run --project local`: 616 files, 18426 passed, 1 todo. - `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 18 touched files, counted with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date. - **Gates:** `dispatch-gates --commands` derived 79 families, the same set as stages 13 to 18, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. - The five roster families whose rosters sit under a touched directory were also run, and each exits 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - The reconciliation printed `STALE TREE`: `scripts/engine-double-contract.pinned.json` moved on `main` after the base. This diff adds and changes no engine double, `check:engine-double-contract` exits 0 on this tree, and the queue re-runs it on the merged generation. - **ESLint, a proven narrowing:** `--no-inline-config` over the 18 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 18 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 164 changed lines. ## `main` since the base Re-fetched just before this PR opened, `origin/main` was two commits past the base (`5e0b489bca`: objectstack-ai#21871, objectstack-ai#21873). Neither touches any of the 18 files, and neither touches `packages/spec`. So `main` was not merged. `git merge-tree` onto `5e0b489bca` is clean. No open PR touches the 18 files. ## Acceptance notes - **No needle in this group.** The one "other" string is an `expect` message, not the expected value of an assertion over a source docblock. The three known needles are untouched. - **Same-id test titles in this card's later stages** go with those stages: `api/protocol.test.ts:677` (objectstack-ai#4286), `ui/dashboard.test.ts:717` (objectstack-ai#15680) and `ui/view-authoring-wire-split.test.ts:192` (objectstack-ai#4721). - **Same-id test titles in other packages** are their lanes' test-string shares. A search of `describe` / `it` / `test` lines outside `packages/spec/src` finds 49 lines citing ids this PR handled, in 12 packages: `service-datasource` 18 (8 files), `metadata-protocol` 7 (4), `objectql` 6 (4), `cli` 5 (3), `lint` 4 (3), `driver-sql` 3 (3), and one each in `client`, `driver-turso`, `platform-objects`, `plugin-security`, `rest` and `service-settings`. Examples: `cli/src/commands/database-driver-flag-derivation.test.ts:63` ("objectstack-ai#6969 — …"), `driver-sql/src/sql-driver-unique-tenancy.test.ts:48` ("(objectstack-ai#3696)"), `lint/src/validate-searchable-fields.test.ts:809` ("[objectstack-ai#6674] …"). - **Code comments still carry ids** in these files and their sources, for example the `[objectstack-ai#6674]` and `[objectstack-ai#4483]` blocks in `search-fields.test.ts`, the `[objectstack-ai#16066]` header of `query-transport.test.ts` and `data/search-fields.ts:198`. Comments are not this card's share, and none is touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21817
Clause-②: no
What changes
A slot in a list scoped to one package (
getMetaItems({ type, packageId }),GET /api/v1/meta/:type?package=, andgetMetaItemsForExecution, which reads through the same method) now serves the rowgetMetaItemnaming that package serves: the package's own row, else the package-less row (ADR-0048), the organization's rows before the env-wide rows (ADR-0005). The list's membership is unchanged. It still lists only the items the package ships.readFlattenedMetaItemsinpackages/metadata-protocol/src/protocol.ts, and the merge it calls,mergePackageAwareOverlay. Nopackages/specchange, no export change, no new error code.standInon a record). The merge picks a slot's stored row throughservedStoredRow, which walksservedOverlayRowCandidates, the one orderfindServedOverlayRowand the unscoped list already share. There is no second resolver.readActiveOverlayRows({ type }, orgId)for the lock (the lock-row read). That set is filtered back to the rows whosepackage_idis null (standInRows).previewDraftsand a package: one package-agnostic draft read per scope, filtered back to the package-less rows (standInDraftRecords).unseatedset. A later layer (the draft preview, the MetadataService listing, the view-container expansion) may still seat it. Whatever is still recorded after the last merge is dropped.resolveOverlayLockLayerfrom every row in scope (PR fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) #21844).H1: the scoped read at the base
At
18fe6815a2. Theprotocol.tsblob there is182c66778c, the same blob as at9f9510f25e, the merge base of this head. WithpackageIdset,readFlattenedMetaItems:listItems(type, packageId));readActiveOverlayRows(request, orgId), whosequeryByOrgputspackage_id = packageIdin thewhere. That is the package's own rows only, env-wide and the organization's;lockRows);package_id = packageIdonly, and keeps only the MetadataService items stamped with the package.Census: the scoped slot against
getMetaItemnaming the package, base vs this headEngine double. PR #21815's census names "16 arrangements over five rows" but does not list them, so this census runs their superset:
view(as PR fix(metadata-protocol): a package's list slot serves the stored row getMetaItem naming that package serves, in every row order (#21804) #21815) and ondashboard.A comparison is a case where the scoped list has a slot for the name.
The figures are identical on
viewand ondashboard. The base column is the card's defect class (25 of 240 on PR #21815's subset), measured over every arrangement.H3: membership unchanged, nothing else moved
_packageId) differs base vs head in 0 of 5216 lists. Slot-count changes: 0.getMetaItemnaming the package (1126 of 1126):getMetaItemdumps: 0 of 5216 differ.H4: the lock stays the item's
Lock census on
dashboard, with A's artifact, every subset and order, one row at a time declaringno-overlay,no-deleteorfull, with and without an organization: 7830 cases._lock,_lockReason) differs base vs head in 0 of 7830. That includes the 993 cases where the served row moved.getMetaItemenvelopes: 0 differ._lockequals the envelope lock in 7830 of 7830, the reason equals the envelope item's in 7830 of 7830, and the label equals the envelope item's in 7830 of 7830. At base the label matched in 6837 of 7830.Pins
protocol.scoped-list-fallback.test.ts, 155 tests:getMetaItemnaming the package serves the same. Where it ships nothing, the scoped list has no slot.dashboardandview); the organization's package-less row over an env-wide row of A; the organization's row of A over the env-wide package-less row.getMetaItem's envelope, for three lock levels on either row.protocol.list-slot-prefer-local.test.ts: its docblock's "out of this card" paragraph now points at the new pin file. No test changed.Reverse verification
On the committed head
b5492dce3e. Every restore isgit checkout HEAD -- PATH, proven by the blob equal to HEAD's and an emptygit diff HEAD.protocol.tsrestored whole (blob182c66778c)standInRowsemptied)standInDraftRecordsemptied)scripts/ablation-replace.mjs: anchor 1 → 0, blobc96ce0d942→1935e0b66f(A) and798b96b4a4(B). Each was restored toc96ce0d942, HEAD's blob, withgit diff HEADempty. After the restore both pin files ran 240 of 240 green../protocol.js, a relative import that vitest resolves tosrc/. Nodist/is on the path, so no rebuild was owed between the legs.Tests (at
b5492dce3e)specthroughmetadata) was built first.pnpm --filter @objectstack/metadata-protocol build:check-dts-emitted2/2 declared declaration files present.pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.tsc --listFilescompiles 218 of the package's test files, both pin files among them.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: Test Files 215 passed, 3 skipped (218). Tests 27900 passed, 19 skipped (27919).Gates (at
b5492dce3e)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths: 5 paths against merge base9f9510f25, 72 commands. All 72 ran and exited 0.--ranprinted: "✓ dispatch-gates --ran: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED".check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-paths. They are re-run against this PR in its report on the card.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchorsandcheck:adr-anchors.protocol.tsalone. All exited 0:check-adr-0087-registration,check-empty-changesetandcheck-scripts-symbol-anchors, each with its self-test;release-rehearsal-clone --self-testandrelease-pending-publish --self-test;check:agent-test-spelling,check:bash32-floor,check:cli-command-ids,check:engine-double-contract,check:entry-guard,check:objectql-double-limit,check:objectui-changesetandcheck:parse-guard;check:pm-changeset-deadline-census,check:pnpm-filter-targets,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher.check:engine-double-contract: the pin file'sfindOnedouble has its row inscripts/engine-double-contract.pinned.json. Re-runningnode scripts/check-engine-double-contract.mjs --writeleaves the file byte-identical (blobbc77050a7b).pnpm lintis CI's run:pnpm exec eslint --no-inline-config --format jsonover the 3 changed TypeScript files linted 3 files with 0 errors and 0 warnings.eslint.config.mjs's block for every TypeScript and JavaScript file, minus the build directories. None of the three is ignored.parserOptions.project), so this diff cannot move any untouched file's verdict.dispatch-gatesnames as CI's own. They are left to CI.Changeset
.changeset/21817-scoped-list-fallback.md:@objectstack/metadata-protocolpatch,Clause-②: no. It says that a package-scoped list now serves a package-less customization of an item the package ships, asgetMetaItemnaming the package does, and that its membership is unchanged.Acceptance notes
getMetaItemnaming that package still answers a package-less row of the name (the by-name fallback). The census counts 63 such cases for A and 218 for B per type, identical at base and head.sys_metadataread per scope (the package-agnostic drafts). The active arm makes none, because it reuses the lock-row read.Files
packages/metadata-protocol/src/protocol.ts: the fix.packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts: the pins (new).packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts: docblock pointer only.scripts/engine-double-contract.pinned.json: one ledger row for the new pin file's double..changeset/21817-scoped-list-fallback.md.Generated by Claude Code